diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 00297e22a..e1ecc5091 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -55,7 +55,7 @@ jobs: run: "support/scripts/unit-test" - name: "Unit Test Results" - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: test-results diff --git a/.github/workflows/continuous_deployment.yml b/.github/workflows/continuous_deployment.yml index a8dee653d..045008d36 100644 --- a/.github/workflows/continuous_deployment.yml +++ b/.github/workflows/continuous_deployment.yml @@ -46,7 +46,7 @@ jobs: run: "support/scripts/unit-test" - name: "Unit Test Results" - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: test-results @@ -61,7 +61,7 @@ jobs: CONFIGURATION: Release - name: "Upload App Bundle" - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: app-bundle path: | diff --git a/.idea/detekt.xml b/.idea/detekt.xml index ee7289c6b..6ecaedb77 100644 --- a/.idea/detekt.xml +++ b/.idea/detekt.xml @@ -1,7 +1,12 @@ - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index a269cb1aa..71d57e69b 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,13 +1,53 @@ \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 84e6c09b2..55a72f76c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,6 +8,7 @@ plugins { id("com.google.firebase.crashlytics") id("com.google.devtools.ksp") alias(libs.plugins.compose.compiler) + alias(libs.plugins.detekt) } android { @@ -268,6 +269,10 @@ android { implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.hilt.work) ksp(libs.androidx.hilt.compiler) + + detektPlugins(libs.detekt.formatting) + + lintChecks(libs.compose.lint.checks) } buildFeatures.buildConfig = true diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt new file mode 100644 index 000000000..1a92a7774 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt @@ -0,0 +1,49 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData + +@Composable +fun AddToPlaylistSubmenu( + modifier: Modifier = Modifier, + genre: Genre, + expanded: Boolean = false, + onDismiss: () -> Unit = {}, + playlists: List, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + onShowCreatePlaylistDialog: (genre: Genre) -> Unit +) { + val playlistData = PlaylistData.Genres(genre) + + DropdownMenu( + modifier = modifier, + expanded = expanded, + onDismissRequest = onDismiss + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.playlist_menu_create_playlist)) }, + onClick = { + onShowCreatePlaylistDialog(genre) + onDismiss() + } + ) + + for (playlist in playlists) { + DropdownMenuItem( + text = { Text(playlist.name) }, + onClick = { + onAddToPlaylist(playlist, playlistData) + onDismiss() + } + ) + } + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/FastScroller.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/FastScroller.kt new file mode 100644 index 000000000..69fe03937 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/FastScroller.kt @@ -0,0 +1,354 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.simplecityapps.shuttle.ui.theme.AppTheme +import kotlin.math.roundToInt +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun FastScroller( + getPopupText: (index: Int) -> String?, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + track: @Composable BoxScope.() -> Unit = { + DefaultTrack(modifier = Modifier.fillMaxHeight()) + }, + thumb: @Composable () -> Unit = { + DefaultThumb() + }, + popup: @Composable (index: Int) -> Unit = { currentItemIndex -> + DefaultPopup(text = getPopupText(currentItemIndex)) + } +) { + val coroutineScope = rememberCoroutineScope() + val density = LocalDensity.current + + // Drag-related state. + var isDragging by remember { mutableStateOf(false) } + var dragThumbOffsetPx by remember { mutableFloatStateOf(0f) } + var initialThumbOffset by remember { mutableFloatStateOf(0f) } + var cumulativeDrag by remember { mutableFloatStateOf(0f) } + var measuredThumbY by remember { mutableFloatStateOf(0f) } + var measuredThumbSize by remember { mutableStateOf(IntSize.Zero) } + var isVisible by remember { mutableStateOf(true) } + + // Auto-hide the scroller when not scrolling or dragging. + LaunchedEffect(state.isScrollInProgress, isDragging) { + if (!state.isScrollInProgress && !isDragging) { + delay(1500) + if (!state.isScrollInProgress && !isDragging) { + isVisible = false + } + } else { + isVisible = true + } + } + AnimatedVisibility( + visible = isVisible, + enter = slideInHorizontally(initialOffsetX = { measuredThumbSize.width }), + exit = slideOutHorizontally(targetOffsetX = { measuredThumbSize.width }) + ) { + BoxWithConstraints( + modifier = modifier.wrapContentWidth(Alignment.End), + contentAlignment = Alignment.TopEnd + ) { + // Use the available maxHeight as the viewport height. + val viewportHeightPx = with(density) { maxHeight.toPx() } + val totalItemsCount by remember { + derivedStateOf { state.layoutInfo.totalItemsCount } + } + + // Compute the thumb scroll state using an average item height. + val thumbScrollState = computeThumbScrollState( + state = state, + totalItemsCount = totalItemsCount, + viewportHeightPx = viewportHeightPx, + thumbHeight = measuredThumbSize.height + ) + val computedThumbOffsetPx = thumbScrollState.computedThumbOffsetPx + + // When dragging, use the user-controlled offset; otherwise, use the computed value. + val thumbOffsetPx = if (isDragging) dragThumbOffsetPx else computedThumbOffsetPx + + // Calculate the thumb center and current item index. + val thumbCenter = thumbOffsetPx + measuredThumbSize.height / 2 + val thumbCenterFraction = ((thumbCenter - measuredThumbSize.height / 2) / (viewportHeightPx - measuredThumbSize.height)).coerceIn(0f, 1f) + val currentItemIndex = if (isDragging) { + (thumbCenterFraction * (totalItemsCount - 1)).roundToInt() + } else { + thumbScrollState.currentItemIndex + } + + // Draw the track. + track() + + // Draw the thumb with drag gesture handling. + Box( + modifier = Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .offset { IntOffset(x = 0, y = thumbOffsetPx.roundToInt()) } + .onGloballyPositioned { coordinates -> + measuredThumbSize = coordinates.size + measuredThumbY = coordinates.positionInParent().y + } + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { _: Offset -> + isDragging = true + initialThumbOffset = measuredThumbY + cumulativeDrag = 0f + dragThumbOffsetPx = measuredThumbY + }, + onVerticalDrag = { change, dragAmount -> + change.consume() + cumulativeDrag += dragAmount + val (newDragOffset, scrollTarget) = computeDragScrollOffset( + initialThumbOffset = initialThumbOffset, + cumulativeDrag = cumulativeDrag, + viewportHeightPx = viewportHeightPx, + thumbHeight = measuredThumbSize.height, + totalScrollRangePx = thumbScrollState.totalScrollRangePx, + totalItemsCount = totalItemsCount, + averageItemHeight = thumbScrollState.averageItemHeight + ) + dragThumbOffsetPx = newDragOffset + coroutineScope.launch { + state.scrollToItem(scrollTarget.first, scrollTarget.second) + } + }, + onDragEnd = { isDragging = false }, + onDragCancel = { isDragging = false } + ) + }, + contentAlignment = Alignment.TopEnd + ) { + thumb() + } + + // Position the popup so its bottom aligns with the thumb center. + var popupHeight by remember { mutableFloatStateOf(0f) } + Box( + modifier = Modifier + .offset { + val desiredPopupY = thumbCenter - popupHeight + val finalPopupY = desiredPopupY.coerceAtLeast(0f) + IntOffset(0, finalPopupY.roundToInt()) + } + .onGloballyPositioned { coordinates -> + popupHeight = coordinates.size.height.toFloat() + } + ) { + AnimatedVisibility( + visible = isDragging || LocalInspectionMode.current, + enter = fadeIn(tween(durationMillis = 150)), + exit = fadeOut(tween(durationMillis = 200)) + ) { + popup(currentItemIndex) + } + } + } + } +} + +@Composable +fun DefaultTrack( + modifier: Modifier = Modifier, + trackWidth: Dp = 7.dp, + trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) +) { + Box( + modifier = modifier + .padding(top = 16.dp, bottom = 16.dp) + .clip(RoundedCornerShape(percent = 50)) + .width(trackWidth) + .background(trackColor) + ) +} + +@Composable +fun DefaultThumb( + modifier: Modifier = Modifier, + thumbWidth: Dp = 8.dp, + thumbHeight: Dp = 52.dp, + thumbColor: Color = MaterialTheme.colorScheme.primary +) { + Box( + modifier = modifier + .size(width = thumbWidth, height = thumbHeight) + .clip(RoundedCornerShape(percent = 50)) + .background(thumbColor) + ) +} + +@Composable +fun DefaultPopup( + text: String?, + modifier: Modifier = Modifier +) { + text?.let { + Box( + modifier = modifier + .padding(end = 16.dp) + .sizeIn(minWidth = 64.dp, minHeight = 64.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomStartPercent = 50, + bottomEndPercent = 0 + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = it, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + } + } +} + +/** + * Data class holding computed thumb scroll state, including an estimated average item height. + */ +private data class ThumbScrollState( + val computedThumbOffsetPx: Float, + val currentItemIndex: Int, + val totalScrollRangePx: Float, + val averageItemHeight: Float +) + +/** + * Computes the thumb scroll state using the average height of visible items. + * + * This makes the fast scroller agnostic to fixed item heights. + */ +private fun computeThumbScrollState( + state: LazyListState, + totalItemsCount: Int, + viewportHeightPx: Float, + thumbHeight: Int +): ThumbScrollState { + val visibleItems = state.layoutInfo.visibleItemsInfo + val averageItemHeight = if (visibleItems.isNotEmpty()) { + visibleItems.sumOf { it.size.toLong() }.toFloat() / visibleItems.size + } else { + 1f + } + val totalContentHeightPx = averageItemHeight * totalItemsCount + val totalScrollRangePx = (totalContentHeightPx - viewportHeightPx).coerceAtLeast(1f) + val currentScrollOffsetPx = state.firstVisibleItemIndex * averageItemHeight + state.firstVisibleItemScrollOffset + val scrollFraction = (currentScrollOffsetPx / totalScrollRangePx).coerceIn(0f, 1f) + val computedThumbOffsetPx = scrollFraction * (viewportHeightPx - thumbHeight) + val thumbCenter = computedThumbOffsetPx + thumbHeight / 2 + val thumbCenterFraction = ((thumbCenter - thumbHeight / 2) / (viewportHeightPx - thumbHeight)).coerceIn(0f, 1f) + val currentItemIndex = (thumbCenterFraction * (totalItemsCount - 1)).roundToInt() + return ThumbScrollState( + computedThumbOffsetPx = computedThumbOffsetPx, + currentItemIndex = currentItemIndex, + totalScrollRangePx = totalScrollRangePx, + averageItemHeight = averageItemHeight + ) +} + +/** + * Computes a new drag offset and corresponding target scroll position based on the average item height. + */ +private fun computeDragScrollOffset( + initialThumbOffset: Float, + cumulativeDrag: Float, + viewportHeightPx: Float, + thumbHeight: Int, + totalScrollRangePx: Float, + totalItemsCount: Int, + averageItemHeight: Float +): Pair> { + val newDragOffset = (initialThumbOffset + cumulativeDrag).coerceIn(0f, viewportHeightPx - thumbHeight) + val newFraction = newDragOffset / (viewportHeightPx - thumbHeight) + val newScrollOffsetPx = newFraction * totalScrollRangePx + val targetIndex = (newScrollOffsetPx / averageItemHeight).toInt().coerceIn(0, totalItemsCount - 1) + val targetItemOffset = (newScrollOffsetPx % averageItemHeight).toInt() + return newDragOffset to (targetIndex to targetItemOffset) +} + +@Preview(showBackground = true) +@Composable +private fun FastScrollPreview() { + AppTheme { + val state = rememberLazyListState(initialFirstVisibleItemIndex = 2) + Box(modifier = Modifier.padding(vertical = 16.dp)) { + LazyColumn( + state = state + ) { + items(20) { + Text( + modifier = Modifier + .sizeIn(minHeight = 56.dp) + .padding(horizontal = 16.dp), + text = "Item $it" + ) + } + } + FastScroller( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 16.dp), + state = state, + getPopupText = { (it).toString() } + ) + } + } +} 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 9a7ef6d6c..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt +++ /dev/null @@ -1,75 +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 = ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item_genre, parent, false)) - - override fun viewType(): Int = 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 = genre.hashCode() - - override fun areContentsTheSame(other: Any): Boolean = 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/GenreList.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt new file mode 100644 index 000000000..b3cd57c30 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt @@ -0,0 +1,117 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.simplecityapps.mediaprovider.Progress +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData + +@Composable +fun GenreList( + viewState: GenreListViewModel.ViewState, + playlists: List, + setLoadingState: (GenreListFragment.LoadingState) -> Unit, + setLoadingProgress: (progress: Progress?) -> Unit, + onSelectGenre: (genre: Genre) -> Unit, + onPlayGenre: (Genre) -> Unit, + onAddToQueue: (Genre) -> Unit, + onPlayNext: (Genre) -> Unit, + onExclude: (Genre) -> Unit, + onEditTags: (Genre) -> Unit, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + onShowCreatePlaylistDialog: (genre: Genre) -> Unit +) { + when (viewState) { + is GenreListViewModel.ViewState.Scanning -> { + setLoadingState(GenreListFragment.LoadingState.Scanning) + setLoadingProgress(viewState.progress) + } + + is GenreListViewModel.ViewState.Loading -> { + setLoadingState(GenreListFragment.LoadingState.Loading) + } + + is GenreListViewModel.ViewState.Ready -> { + if (viewState.genres.isEmpty()) { + setLoadingState(GenreListFragment.LoadingState.Empty) + } else { + setLoadingState(GenreListFragment.LoadingState.None) + } + + GenreList( + genres = viewState.genres, + playlists = playlists, + onSelectGenre = onSelectGenre, + onPlayGenre = onPlayGenre, + onAddToQueue = onAddToQueue, + onPlayNext = onPlayNext, + onExclude = onExclude, + onEditTags = onEditTags, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog + ) + } + } +} + +@Composable +private fun GenreList( + genres: List, + playlists: List, + onSelectGenre: (genre: Genre) -> Unit, + onPlayGenre: (Genre) -> Unit, + onAddToQueue: (Genre) -> Unit, + onPlayNext: (Genre) -> Unit, + onExclude: (Genre) -> Unit, + onEditTags: (Genre) -> Unit, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + modifier: Modifier = Modifier, + onShowCreatePlaylistDialog: (genre: Genre) -> Unit +) { + val state = rememberLazyListState() + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .testTag("genres-list-lazy-column"), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp), + state = state + ) { + items(genres + genres + genres + genres) { genre -> + GenreListItem( + genre = genre, + playlists = playlists, + onSelectGenre = onSelectGenre, + onPlayGenre = onPlayGenre, + onAddToQueue = onAddToQueue, + onPlayNext = onPlayNext, + onExclude = onExclude, + onEditTags = onEditTags, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog + ) + } + } + FastScroller( + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), + state = state, + getPopupText = { index -> + (genres + genres + genres + genres)[index].name.firstOrNull()?.toString() + } + ) + } +} 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 4622f18f7..3cd486b28 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,27 +1,24 @@ 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.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ui.common.TagEditorMenuSanitiser +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.Song 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 @@ -29,6 +26,7 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFr import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuPresenter import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuView +import com.simplecityapps.shuttle.ui.theme.AppTheme import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -36,25 +34,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( @@ -69,116 +60,130 @@ class GenreListFragment : ) { super.onViewCreated(view, savedInstanceState) - playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager) - - adapter = - object : SectionedAdapter(viewLifecycleOwner.lifecycleScope) { - override fun getSectionName(viewBinder: ViewBinder?): String? = (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) - } - - override fun onResume() { - super.onResume() - - presenter.loadGenres(false) - } - override fun onPause() { - super.onPause() - - recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState) - super.onSaveInstanceState(outState) + composeView = view.findViewById(R.id.composeView) + + composeView.setContent { + val viewState by viewModel.viewState.collectAsState() + + val theme by viewModel.theme.collectAsStateWithLifecycle() + val accent by viewModel.accent.collectAsStateWithLifecycle() + val extraDark by viewModel.extraDark.collectAsStateWithLifecycle() + + AppTheme( + theme = theme, + accent = accent, + extraDark = extraDark + ) { + GenreList( + viewState = viewState, + playlists = playlistMenuPresenter.playlists, + setLoadingState = { + setLoadingState(it) + }, + setLoadingProgress = { + setLoadingProgress(it) + }, + onSelectGenre = { + onGenreSelected(it) + }, + onPlayGenre = { genre -> + viewModel.play(genre) { result -> + result.onFailure { error -> showLoadError(error as Error) } + } + }, + onAddToQueue = { genre -> + viewModel.addToQueue(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } + }, + onPlayNext = { genre -> + viewModel.playNext(genre) { result -> + result.onSuccess { genre -> + onAddedToQueue(genre) + } + } + }, + onExclude = { genre -> + viewModel.exclude(genre) + }, + onEditTags = { genre -> + viewModel.editTags(genre) { result -> + result.onSuccess { songs -> + showTagEditor(songs) + } + } + }, + onAddToPlaylist = { playlist, playlistData -> + playlistMenuPresenter.addToPlaylist(playlist, playlistData) + }, + onShowCreatePlaylistDialog = { genre -> + CreatePlaylistDialogFragment.newInstance( + PlaylistData.Genres(genre), + context?.getString(R.string.playlist_create_dialog_playlist_name_hint) + ).show(childFragmentManager) + } + ) + } + } } override fun onDestroyView() { - presenter.unbindView() playlistMenuPresenter.unbindView() super.onDestroyView() } - // GenreListContract.View Implementation - - override fun setGenres( - genres: List, - resetPosition: Boolean - ) { - if (resetPosition) { - adapter.clear() - } - - val data = genres.map { genre -> GenreBinder(genre, this) }.toMutableList() - - adapter.update(data) { - recyclerViewState?.let { - recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) - recyclerViewState = null - } - } - } - - 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?) { + private 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, @@ -187,50 +192,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( @@ -245,8 +206,13 @@ class GenreListFragment : companion object { const val TAG = "GenreListFragment" - const val ARG_RECYCLER_STATE = "recycler_state" - fun newInstance() = GenreListFragment() } + + sealed class LoadingState { + data object Scanning : LoadingState() + data object Loading : LoadingState() + data object Empty : LoadingState() + data object None : LoadingState() + } } diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListItem.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListItem.kt new file mode 100644 index 000000000..003fb01ce --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListItem.kt @@ -0,0 +1,107 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.clickable +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.MediaProviderType +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager +import com.simplecityapps.shuttle.sorting.PlaylistSongSortOrder +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData +import com.simplecityapps.shuttle.ui.theme.AppTheme + +@Composable +fun GenreListItem( + genre: Genre, + playlists: List, + modifier: Modifier = Modifier, + onSelectGenre: (genre: Genre) -> Unit = {}, + onPlayGenre: (Genre) -> Unit = {}, + onAddToQueue: (Genre) -> Unit = {}, + onPlayNext: (Genre) -> Unit = {}, + onExclude: (Genre) -> Unit = {}, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit = { _, _ -> }, + onEditTags: (Genre) -> Unit = {}, + onShowCreatePlaylistDialog: (genre: Genre) -> Unit = {} +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + Modifier + .padding(start = 8.dp) + .weight(1f) + .clickable { onSelectGenre(genre) } + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = genre.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + // Todo: Manually replacing "{count}" is not ideal. But, the Phrase library doesn't render correctly in Compose. + // Will need to come up with a better solution. + Text( + modifier = Modifier.fillMaxWidth(), + text = pluralStringResource(R.plurals.songsPlural, genre.songCount, genre.songCount) + .replace("{count}", genre.songCount.toString()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + } + GenreMenu( + genre, + playlists = playlists, + onPlayGenre = onPlayGenre, + onAddToQueue = onAddToQueue, + onPlayNext = onPlayNext, + onExclude = onExclude, + onEditTags = onEditTags, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun GenreListItemPreview() { + AppTheme( + accent = GeneralPreferenceManager.Accent.Default + ) { + GenreListItem( + genre = Genre( + name = "Genre", + songCount = 1, + duration = 10, + mediaProviders = listOf(MediaProviderType.MediaStore) + ), + playlists = listOf( + Playlist( + id = 1, + name = "Playlist", + songCount = 1, + duration = 10, + sortOrder = PlaylistSongSortOrder.SongName, + mediaProvider = MediaProviderType.MediaStore, + externalId = null + ) + ) + ) + } +} 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 78dc0fb81..000000000 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt +++ /dev/null @@ -1,186 +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 -) : BasePresenter(), - GenreListContract.Presenter { - 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? = 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..058e651e0 --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt @@ -0,0 +1,119 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import androidx.annotation.OpenForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simplecityapps.mediaprovider.MediaImportObserver +import com.simplecityapps.mediaprovider.Progress +import com.simplecityapps.mediaprovider.SongImportState +import com.simplecityapps.mediaprovider.repository.genres.GenreQuery +import com.simplecityapps.mediaprovider.repository.genres.GenreRepository +import com.simplecityapps.mediaprovider.repository.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.persistence.GeneralPreferenceManager +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.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +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 queueManager: QueueManager, + preferenceManager: GeneralPreferenceManager, + mediaImportObserver: MediaImportObserver +) : ViewModel() { + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState = _viewState.asStateFlow() + + init { + combine( + genreRepository.getGenres(GenreQuery.All()), + mediaImportObserver.songImportState, + mediaImportObserver.playlistImportState + ) { genres, songImportState, playlistImportState -> + if (songImportState is SongImportState.ImportProgress) { + _viewState.emit(ViewState.Scanning(songImportState.progress)) + } else { + _viewState.emit(ViewState.Ready(genres)) + } + } + .onStart { + _viewState.emit(ViewState.Loading) + } + .launchIn(viewModelScope) + } + + val theme = preferenceManager.theme(viewModelScope) + val accent = preferenceManager.accent(viewModelScope) + val extraDark = preferenceManager.extraDark(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() + + sealed class ViewState { + data class Scanning(val progress: Progress?) : ViewState() + data object Loading : ViewState() + data class Ready(val genres: List) : ViewState() + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt new file mode 100644 index 000000000..15e0bc9eb --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt @@ -0,0 +1,118 @@ +package com.simplecityapps.shuttle.ui.screens.library.genres + +import androidx.compose.foundation.layout.size +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.model.Genre +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData + +@Composable +fun GenreMenu( + genre: Genre, + playlists: List, + onPlayGenre: (Genre) -> Unit, + onAddToQueue: (Genre) -> Unit, + onPlayNext: (Genre) -> Unit, + onExclude: (Genre) -> Unit, + onEditTags: (Genre) -> Unit, + onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit, + modifier: Modifier = Modifier, + onShowCreatePlaylistDialog: (genre: Genre) -> Unit +) { + var isMenuOpened by remember { mutableStateOf(false) } + var isAddToPlaylistSubmenuOpen by remember { mutableStateOf(false) } + + IconButton( + modifier = modifier, + onClick = { isMenuOpened = true } + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.MoreVert, + contentDescription = "Genre menu", + tint = MaterialTheme.colorScheme.onBackground + ) + DropdownMenu( + expanded = isMenuOpened, + onDismissRequest = { isMenuOpened = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_play)) }, + onClick = { + onPlayGenre(genre) + isMenuOpened = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_add_to_queue)) }, + onClick = { + onAddToQueue(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 = { + onPlayNext(genre) + isMenuOpened = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_title_exclude)) }, + onClick = { + onExclude(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 = { + onEditTags(genre) + isMenuOpened = false + } + ) + } + } + AddToPlaylistSubmenu( + genre = genre, + expanded = isAddToPlaylistSubmenuOpen, + onDismiss = { isAddToPlaylistSubmenuOpen = false }, + playlists = playlists, + onAddToPlaylist = onAddToPlaylist, + onShowCreatePlaylistDialog = onShowCreatePlaylistDialog + ) + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt index 0d4c48e10..54541ed91 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt @@ -12,7 +12,7 @@ fun Theme.getColorScheme(useDark: Boolean = false): ColorScheme = if (useDark) d val ShuttleTheme = Theme( light = ColorScheme( - primary = Color(0xFF005389), + primary = Color(0xFF0492ea), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF0079C3), onPrimaryContainer = Color(0xFFFFFFFF), @@ -39,7 +39,7 @@ val ShuttleTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFF2C3137), inverseOnSurface = Color(0xFFEDF1F8), - inversePrimary = Color(0xFF9BCBFF), + inversePrimary = Color(0xFF0068A5), surfaceDim = Color(0xFFD7DAE2), surfaceBright = Color(0xFFF8F9FF), surfaceContainerLowest = Color(0xFFFFFFFF), @@ -47,11 +47,11 @@ val ShuttleTheme = Theme( surfaceContainer = Color(0xFFEBEEF6), surfaceContainerHigh = Color(0xFFE5E8F0), surfaceContainerHighest = Color(0xFFDFE2EA), - surfaceTint = Color(0xFF005389) + surfaceTint = Color(0xFF0492ea) ), dark = ColorScheme( - primary = Color(0xFF9BCBFF), - onPrimary = Color(0xFF003256), + primary = Color(0xFF0068A5), + onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF0079C3), onPrimaryContainer = Color(0xFFFFFFFF), secondary = Color(0xFFA8C9F0), @@ -85,13 +85,13 @@ val ShuttleTheme = Theme( surfaceContainer = Color(0xFF1C2025), surfaceContainerHigh = Color(0xFF262A30), surfaceContainerHighest = Color(0xFF31353B), - surfaceTint = Color(0xFF9BCBFF) + surfaceTint = Color(0xFF0068A5) ) ) val OrangeTheme = Theme( light = ColorScheme( - primary = Color(0xFF904A42), + primary = Color(0xFFF44336), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFFFDAD5), onPrimaryContainer = Color(0xFF3B0906), @@ -118,7 +118,7 @@ val OrangeTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFF392E2C), inverseOnSurface = Color(0xFFFFEDEA), - inversePrimary = Color(0xFFFFB4A9), + inversePrimary = Color(0xFFB71C1C), surfaceDim = Color(0xFFE8D6D3), surfaceBright = Color(0xFFFFF8F7), surfaceContainerLowest = Color(0xFFFFFFFF), @@ -126,12 +126,12 @@ val OrangeTheme = Theme( surfaceContainer = Color(0xFFFCEAE7), surfaceContainerHigh = Color(0xFFF7E4E1), surfaceContainerHighest = Color(0xFFF1DEDC), - surfaceTint = Color(0xFF904A42) + surfaceTint = Color(0xFFF44336) ), dark = ColorScheme( - primary = Color(0xFFFFB4A9), - onPrimary = Color(0xFF561E18), + primary = Color(0xFFB71C1C), + onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF73342C), onPrimaryContainer = Color(0xFFFFDAD5), secondary = Color(0xFFE7BDB7), @@ -157,7 +157,7 @@ val OrangeTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFFF1DEDC), inverseOnSurface = Color(0xFF392E2C), - inversePrimary = Color(0xFF904A42), + inversePrimary = Color(0xFFF44336), surfaceDim = Color(0xFF1A1110), surfaceBright = Color(0xFF423735), surfaceContainerLowest = Color(0xFF140C0B), @@ -165,13 +165,13 @@ val OrangeTheme = Theme( surfaceContainer = Color(0xFF271D1C), surfaceContainerHigh = Color(0xFF322826), surfaceContainerHighest = Color(0xFF3D3231), - surfaceTint = Color(0xFFFFB4A9) + surfaceTint = Color(0xFFB71C1C) ) ) val CyanTheme = Theme( light = ColorScheme( - primary = Color(0xFF006A6A), + primary = Color(0xFF00AFAF), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF9CF1F0), onPrimaryContainer = Color(0xFF002020), @@ -198,7 +198,7 @@ val CyanTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFF2B3231), inverseOnSurface = Color(0xFFECF2F1), - inversePrimary = Color(0xFF80D5D4), + inversePrimary = Color(0xFF00AFAF), surfaceDim = Color(0xFFD5DBDA), surfaceBright = Color(0xFFF4FBFA), surfaceContainerLowest = Color(0xFFFFFFFF), @@ -206,11 +206,11 @@ val CyanTheme = Theme( surfaceContainer = Color(0xFFE9EFEE), surfaceContainerHigh = Color(0xFFE3E9E9), surfaceContainerHighest = Color(0xFFDDE4E3), - surfaceTint = Color(0xFF006A6A) + surfaceTint = Color(0xFF00AFAF) ), dark = ColorScheme( - primary = Color(0xFF80D5D4), - onPrimary = Color(0xFF003737), + primary = Color(0xFF00AFAF), + onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF004F4F), onPrimaryContainer = Color(0xFF9CF1F0), secondary = Color(0xFFB0CCCB), @@ -236,7 +236,7 @@ val CyanTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFFDDE4E3), inverseOnSurface = Color(0xFF2B3231), - inversePrimary = Color(0xFF006A6A), + inversePrimary = Color(0xFF00AFAF), surfaceDim = Color(0xFF0E1514), surfaceBright = Color(0xFF343A3A), surfaceContainerLowest = Color(0xFF090F0F), @@ -250,7 +250,7 @@ val CyanTheme = Theme( val PurpleTheme = Theme( light = ColorScheme( - primary = Color(0xFF7F4D7A), + primary = Color(0xFF8B0F8E), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFFFD7F6), onPrimaryContainer = Color(0xFF330833), @@ -277,7 +277,7 @@ val PurpleTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFF352E33), inverseOnSurface = Color(0xFFFAEDF4), - inversePrimary = Color(0xFFF0B3E7), + inversePrimary = Color(0xFF8B0F8E), surfaceDim = Color(0xFFE3D7DD), surfaceBright = Color(0xFFFFF7F9), surfaceContainerLowest = Color(0xFFFFFFFF), @@ -285,11 +285,11 @@ val PurpleTheme = Theme( surfaceContainer = Color(0xFFF7EBF1), surfaceContainerHigh = Color(0xFFF1E5EB), surfaceContainerHighest = Color(0xFFEBDFE6), - surfaceTint = Color(0xFF7F4D7A) + surfaceTint = Color(0xFF8B0F8E) ), dark = ColorScheme( - primary = Color(0xFFF0B3E7), - onPrimary = Color(0xFF4B1F4A), + primary = Color(0xFF8B0F8E), + onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF653661), onPrimaryContainer = Color(0xFFFFD7F6), secondary = Color(0xFFDABFD3), @@ -315,7 +315,7 @@ val PurpleTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFFEBDFE6), inverseOnSurface = Color(0xFF352E33), - inversePrimary = Color(0xFF7F4D7A), + inversePrimary = Color(0xFF8B0F8E), surfaceDim = Color(0xFF171216), surfaceBright = Color(0xFF3E373C), surfaceContainerLowest = Color(0xFF120D11), @@ -323,13 +323,13 @@ val PurpleTheme = Theme( surfaceContainer = Color(0xFF241E22), surfaceContainerHigh = Color(0xFF2F282D), surfaceContainerHighest = Color(0xFF3A3338), - surfaceTint = Color(0xFFF0B3E7) + surfaceTint = Color(0xFF8B0F8E) ) ) val GreenTheme = Theme( light = ColorScheme( - primary = Color(0xFF3B6939), + primary = Color(0xFF4CAF50), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFBCF0B4), onPrimaryContainer = Color(0xFF002204), @@ -356,7 +356,7 @@ val GreenTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFF2D322C), inverseOnSurface = Color(0xFFEFF2E9), - inversePrimary = Color(0xFFA1D39A), + inversePrimary = Color(0xFF306E32), surfaceDim = Color(0xFFD8DBD2), surfaceBright = Color(0xFFF7FBF1), surfaceContainerLowest = Color(0xFFFFFFFF), @@ -364,11 +364,11 @@ val GreenTheme = Theme( surfaceContainer = Color(0xFFECEFE6), surfaceContainerHigh = Color(0xFFE6E9E0), surfaceContainerHighest = Color(0xFFE0E4DB), - surfaceTint = Color(0xFF3B6939) + surfaceTint = Color(0xFF4CAF50) ), dark = ColorScheme( - primary = Color(0xFFA1D39A), - onPrimary = Color(0xFF0A390F), + primary = Color(0xFF306E32), + onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF235024), onPrimaryContainer = Color(0xFFBCF0B4), secondary = Color(0xFFBACCB3), @@ -394,7 +394,7 @@ val GreenTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFFE0E4DB), inverseOnSurface = Color(0xFF2D322C), - inversePrimary = Color(0xFF3B6939), + inversePrimary = Color(0xFF4CAF50), surfaceDim = Color(0xFF10140F), surfaceBright = Color(0xFF363A34), surfaceContainerLowest = Color(0xFF0B0F0A), @@ -402,13 +402,13 @@ val GreenTheme = Theme( surfaceContainer = Color(0xFF1D211B), surfaceContainerHigh = Color(0xFF272B25), surfaceContainerHighest = Color(0xFF323630), - surfaceTint = Color(0xFFA1D39A) + surfaceTint = Color(0xFF306E32) ) ) val AmberTheme = Theme( light = ColorScheme( - primary = Color(0xFF855318), + primary = Color(0xFFFF9800), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFFFDCBE), onPrimaryContainer = Color(0xFF2C1600), @@ -435,7 +435,7 @@ val AmberTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFF372F28), inverseOnSurface = Color(0xFFFDEEE3), - inversePrimary = Color(0xFFFDB975), + inversePrimary = Color(0xFFBA6F00), surfaceDim = Color(0xFFE6D7CD), surfaceBright = Color(0xFFFFF8F5), surfaceContainerLowest = Color(0xFFFFFFFF), @@ -443,11 +443,11 @@ val AmberTheme = Theme( surfaceContainer = Color(0xFFFAEBE0), surfaceContainerHigh = Color(0xFFF4E5DB), surfaceContainerHighest = Color(0xFFEFE0D5), - surfaceTint = Color(0xFF855318) + surfaceTint = Color(0xFFFF9800) ), dark = ColorScheme( - primary = Color(0xFFFDB975), - onPrimary = Color(0xFF4A2800), + primary = Color(0xFFBA6F00), + onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFF693C00), onPrimaryContainer = Color(0xFFFFDCBE), secondary = Color(0xFFE1C1A4), @@ -473,7 +473,7 @@ val AmberTheme = Theme( scrim = Color(0xFF000000), inverseSurface = Color(0xFFEFE0D5), inverseOnSurface = Color(0xFF372F28), - inversePrimary = Color(0xFF855318), + inversePrimary = Color(0xFFFF9800), surfaceDim = Color(0xFF19120C), surfaceBright = Color(0xFF403830), surfaceContainerLowest = Color(0xFF130D07), @@ -481,6 +481,6 @@ val AmberTheme = Theme( surfaceContainer = Color(0xFF261E18), surfaceContainerHigh = Color(0xFF302822), surfaceContainerHighest = Color(0xFF3C332C), - surfaceTint = Color(0xFFFDB975) + surfaceTint = Color(0xFFBA6F00) ) ) 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"> - + /> = sharedPreferences.stateFlowForMappedValue( + key = "pref_theme", + default = "0", + getter = { key, default -> getString(key, default) }, + mapper = { value -> Theme.entries[value.toInt()] }, + scope = scope + ) + enum class Accent { Default, Orange, @@ -100,9 +114,17 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences) sharedPreferences.put("pref_theme_accent", value.ordinal.toString()) } get() { - return Accent.values()[sharedPreferences.get("pref_theme_accent", "0").toInt()] + return Accent.entries[sharedPreferences.get("pref_theme_accent", "0").toInt()] } + fun accent(scope: CoroutineScope): StateFlow = sharedPreferences.stateFlowForMappedValue( + key = "pref_theme_accent", + default = "0", + getter = { key, default -> getString(key, default) }, + mapper = { value -> Accent.entries[value.toInt()] }, + scope = scope + ) + var themeExtraDark: Boolean set(value) { sharedPreferences.put("pref_theme_extra_dark", value) @@ -111,6 +133,12 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences) return sharedPreferences.get("pref_theme_extra_dark", false) } + fun extraDark(scope: CoroutineScope): StateFlow = sharedPreferences.stateFlowForBoolean( + key = "pref_theme_extra_dark", + default = false, + scope = scope + ) + var artworkWifiOnly: Boolean set(value) { sharedPreferences.put("artwork_wifi_only", value) @@ -291,7 +319,7 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences) return sharedPreferences.getString("pref_library_tabs_all", null) ?.split(",") ?.map { LibraryTab.valueOf(it) } - ?: LibraryTab.values().toList() + ?: LibraryTab.entries.toList() } var enabledLibraryTabs: List @@ -299,7 +327,7 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences) sharedPreferences.put("pref_library_tabs_enabled", value.joinToString(",")) } get() { - return sharedPreferences.getString("pref_library_tabs_enabled", LibraryTab.values().joinToString(",")) + return sharedPreferences.getString("pref_library_tabs_enabled", LibraryTab.entries.joinToString(",")) ?.split(",") ?.mapNotNull { try { diff --git a/android/core/src/main/java/com/simplecityapps/shuttle/persistence/stateFlowForValue.kt b/android/core/src/main/java/com/simplecityapps/shuttle/persistence/stateFlowForValue.kt new file mode 100644 index 000000000..0242b8906 --- /dev/null +++ b/android/core/src/main/java/com/simplecityapps/shuttle/persistence/stateFlowForValue.kt @@ -0,0 +1,91 @@ +import android.content.SharedPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * Observes changes for a given preference key as a Flow. + * + * @param T The type of the default value. + * @param key The preference key to observe. + * @param default The default value if the key is not set. + * @param getter A lambda to retrieve the preference value. + * It returns a nullable value, and if null, [default] will be used. + */ +private fun SharedPreferences.observeValue( + key: String, + default: T, + getter: SharedPreferences.(String, T) -> T? +): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, changedKey -> + if (changedKey == key) { + // Use the default if the getter returns null. + trySend(prefs.getter(key, default) ?: default) + } + } + registerOnSharedPreferenceChangeListener(listener) + // Emit the current value immediately, using default if necessary. + trySend(getter(key, default) ?: default) + awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } +} + +// Specialized Flow-based observers +fun SharedPreferences.observeInt(key: String, default: Int = 0): Flow = observeValue(key, default) { key, default -> getInt(key, default) } + +fun SharedPreferences.observeBoolean(key: String, default: Boolean = false): Flow = observeValue(key, default) { key, default -> getBoolean(key, default) } + +fun SharedPreferences.observeString(key: String, default: String? = null): Flow = observeValue(key, default) { key, default -> getString(key, default) } + +/** + * Creates a StateFlow for a preference value by mapping it from the stored type to a desired type. + * + * @param T The type of the default value. + * @param M The mapped type. + * @param key The preference key to observe. + * @param default The default value if the key is not set. + * @param getter A lambda to retrieve the preference value. If it returns null, [default] is used. + * @param mapper A lambda that converts the retrieved value ([T]) into the desired type ([M]). + * @param scope The CoroutineScope in which the StateFlow is active. + * @param started Defines the sharing behavior (default: WhileSubscribed with a 5000ms timeout). + */ +fun SharedPreferences.stateFlowForMappedValue( + key: String, + default: T, + getter: SharedPreferences.(String, T) -> T?, + mapper: (T) -> M, + scope: CoroutineScope, + started: SharingStarted = SharingStarted.WhileSubscribed(5000L) +): StateFlow { + // Get the current value, using default if null, and map it. + val initialValue = mapper(getter(key, default) ?: default) + return observeValue(key, default, getter) + .map { value -> mapper(value ?: default) } + .stateIn(scope, started, initialValue) +} + +// Specialized StateFlow-based observers without mapping. +fun SharedPreferences.stateFlowFor( + key: String, + default: Int = 0, + scope: CoroutineScope, + started: SharingStarted = SharingStarted.WhileSubscribed(5000L) +): StateFlow = stateFlowForMappedValue(key, default, { key, default -> getInt(key, default) }, { it }, scope, started) + +fun SharedPreferences.stateFlowForBoolean( + key: String, + default: Boolean = false, + scope: CoroutineScope, + started: SharingStarted = SharingStarted.WhileSubscribed(5000L) +): StateFlow = stateFlowForMappedValue(key, default, { key, default -> getBoolean(key, default) }, { it }, scope, started) + +fun SharedPreferences.stateFlowForString( + key: String, + default: String? = null, + scope: CoroutineScope, + started: SharingStarted = SharingStarted.WhileSubscribed(5000L) +): StateFlow = stateFlowForMappedValue(key, default, { key, default -> getString(key, default) }, { it }, scope, started) diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaImportObserver.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaImportObserver.kt new file mode 100644 index 000000000..aabdab3ae --- /dev/null +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaImportObserver.kt @@ -0,0 +1,90 @@ +package com.simplecityapps.mediaprovider + +import com.simplecityapps.shuttle.model.MediaProviderType +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +@Singleton +class MediaImportObserver @Inject constructor( + mediaImporter: MediaImporter +) : MediaImporter.Listener { + + init { + mediaImporter.listeners.add(this) + } + + private val _songImportState = MutableStateFlow(SongImportState.Idle) + val songImportState: StateFlow = _songImportState.asStateFlow() + + private val _playlistImportState = MutableStateFlow(PlaylistImportState.Idle) + val playlistImportState: StateFlow = _playlistImportState.asStateFlow() + + override fun onStart(providerType: MediaProviderType) { + _songImportState.value = SongImportState.ImportProgress( + providerType = providerType, + message = null, + progress = null + ) + } + + override fun onSongImportProgress( + providerType: MediaProviderType, + message: String, + progress: Progress? + ) { + _songImportState.value = SongImportState.ImportProgress(providerType, message, progress) + } + + override fun onSongImportComplete(providerType: MediaProviderType) { + _songImportState.value = SongImportState.ImportComplete(providerType, null) + } + + override fun onSongImportFailed(providerType: MediaProviderType, message: String?) { + _songImportState.value = SongImportState.ImportComplete(providerType, message) + } + + override fun onPlaylistImportProgress( + providerType: MediaProviderType, + message: String, + progress: Progress? + ) { + _playlistImportState.value = PlaylistImportState.ImportProgress(providerType, message, progress) + } + + override fun onPlaylistImportComplete(providerType: MediaProviderType) { + _playlistImportState.value = PlaylistImportState.ImportComplete(providerType, null) + } + + override fun onPlaylistImportFailed(providerType: MediaProviderType, message: String?) { + _playlistImportState.value = PlaylistImportState.ImportComplete(providerType, message) + } + + override fun onAllComplete() { + // Anyone interested in this event could derive it by observing both state flows + } +} + +sealed class SongImportState { + data object Idle : SongImportState() + data class ImportProgress( + val providerType: MediaProviderType, + val message: String?, + val progress: Progress? + ) : SongImportState() + + data class ImportComplete(val providerType: MediaProviderType, val error: String?) : SongImportState() +} + +sealed class PlaylistImportState { + data object Idle : PlaylistImportState() + data class ImportProgress( + val providerType: MediaProviderType, + val message: String?, + val progress: Progress? + ) : PlaylistImportState() + + data class ImportComplete(val providerType: MediaProviderType, val error: String?) : PlaylistImportState() +} diff --git a/build.gradle.kts b/build.gradle.kts index 49404203a..24cc84d9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.google.services) apply false alias(libs.plugins.firebase.crashlytics) apply false alias(libs.plugins.aboutlibraries) apply false + alias(libs.plugins.detekt) apply false } buildscript { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e66f97ce6..83997de4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,8 @@ agp = "8.8.0" billing-ktx = "7.1.1" circleindicator = "2.1.6" compose = "2025.01.00" +compose-lint-checks = "1.4.2" +detekt = "1.23.7" ksp = "2.1.0-1.0.29" constraintlayout = "2.2.0" converter-moshi = "2.11.0" @@ -107,7 +109,9 @@ androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime-ktx" } billingclient-billingKtx = { module = "com.android.billingclient:billing-ktx", version.ref = "billing-ktx" } +compose-lint-checks = { module = "com.slack.lint.compose:compose-lint-checks", version.ref = "compose-lint-checks" } design-fluentSystemIcons = { module = "com.microsoft.design:fluent-system-icons", version.ref = "fluent-system-icons" } +detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } exoplayer-core = { module = "com.github.timusus.exoplayer:exoplayer-core", version.ref = "exoplayer-shuttle" } exoplayer-extensionFlac = { module = "com.github.timusus.exoplayer:extension-flac", version.ref = "exoplayer-shuttle" } exoplayer-extensionOpus = { module = "com.github.timusus.exoplayer:extension-opus", version.ref = "exoplayer-shuttle" } @@ -161,6 +165,7 @@ vdurmont-semver4j = { module = "com.vdurmont:semver4j", version.ref = "semver4j" aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } android-application = { id = "com.android.application", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"} firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" } google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt-android" } diff --git a/support/scripts/lint b/support/scripts/lint index 506feb02c..76eb04b54 100755 --- a/support/scripts/lint +++ b/support/scripts/lint @@ -7,5 +7,4 @@ VENDOR_DIR="${SUPPORT_DIR}/vendor" PATH="${VENDOR_DIR}/ktlint:$PATH" -ktlint -R "${VENDOR_DIR}/ktlint/ktlint-compose-0.4.22-all.jar" \ - "$@" "**.kt" "!**/generated/**" "!**/build/**" \ No newline at end of file +ktlint "$@" "**.kt" "!**/generated/**" "!**/build/**" \ No newline at end of file diff --git a/support/vendor/ktlint/ktlint-compose-0.4.22-all.jar b/support/vendor/ktlint/ktlint-compose-0.4.22-all.jar deleted file mode 100644 index bf71c7e32..000000000 Binary files a/support/vendor/ktlint/ktlint-compose-0.4.22-all.jar and /dev/null differ