diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumGrid.kt b/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumGrid.kt index 250b7ce7..fe3fb130 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumGrid.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumGrid.kt @@ -1,16 +1,46 @@ package me.vanpetegem.accentor.ui.albums +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ScaffoldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.ui.main.BaseToolbar +import me.vanpetegem.accentor.ui.main.SearchToolbar import me.vanpetegem.accentor.ui.util.FastScrollableGrid @Composable fun AlbumGrid(navController: NavController, albumsViewModel: AlbumsViewModel = hiltViewModel()) { - val albums by albumsViewModel.allAlbums.observeAsState() + val albums by albumsViewModel.filteredAlbums.observeAsState() if (albums != null) { FastScrollableGrid(albums!!, { it.firstCharacter().uppercase() }) { album -> AlbumCard(album, navController) } } } + +@Composable +fun AlbumToolbar(scaffoldState: ScaffoldState, albumsViewModel: AlbumsViewModel = hiltViewModel()) { + val searching by albumsViewModel.searching.observeAsState() + if (searching ?: false) { + val query by albumsViewModel.query.observeAsState() + SearchToolbar(query ?: "", { albumsViewModel.setQuery(it) }) { + albumsViewModel.setSearching(false) + albumsViewModel.setQuery("") + } + } else { + BaseToolbar( + scaffoldState, + extraActions = { + IconButton(onClick = { albumsViewModel.setSearching(true) }) { + Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + } + } + ) + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumsViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumsViewModel.kt index f7bc9b28..187ed8b2 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumsViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/albums/AlbumsViewModel.kt @@ -3,7 +3,11 @@ package me.vanpetegem.accentor.ui.albums import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations.map +import androidx.lifecycle.Transformations.switchMap import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.Normalizer import javax.inject.Inject import me.vanpetegem.accentor.data.albums.Album import me.vanpetegem.accentor.data.albums.AlbumRepository @@ -14,4 +18,23 @@ class AlbumsViewModel @Inject constructor( private val albumRepository: AlbumRepository, ) : AndroidViewModel(application) { val allAlbums: LiveData> = albumRepository.allAlbums + + private val _searching = MutableLiveData(false) + val searching: LiveData = _searching + + private val _query = MutableLiveData("") + val query: LiveData = _query + + val filteredAlbums: LiveData> = switchMap(allAlbums) { albums -> + map(query) { query -> + if (query.equals("")) { + albums + } else { + albums.filter { a -> a.normalizedTitle.contains(Normalizer.normalize(query, Normalizer.Form.NFKD), ignoreCase = true) } + } + } + } + + fun setSearching(value: Boolean) { _searching.value = value } + fun setQuery(value: String) { _query.value = value } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistGrid.kt b/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistGrid.kt index 1ed989c2..66a55a31 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistGrid.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistGrid.kt @@ -1,16 +1,46 @@ package me.vanpetegem.accentor.ui.artists +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ScaffoldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.ui.main.BaseToolbar +import me.vanpetegem.accentor.ui.main.SearchToolbar import me.vanpetegem.accentor.ui.util.FastScrollableGrid @Composable fun ArtistGrid(navController: NavController, artistsViewModel: ArtistsViewModel = hiltViewModel()) { - val artists by artistsViewModel.allArtists.observeAsState() + val artists by artistsViewModel.filteredArtists.observeAsState() if (artists != null) { FastScrollableGrid(artists!!, { it.firstCharacter().uppercase() }) { artist -> ArtistCard(navController, artist) } } } + +@Composable +fun ArtistToolbar(scaffoldState: ScaffoldState, artistsViewModel: ArtistsViewModel = hiltViewModel()) { + val searching by artistsViewModel.searching.observeAsState() + if (searching ?: false) { + val query by artistsViewModel.query.observeAsState() + SearchToolbar(query ?: "", { artistsViewModel.setQuery(it) }) { + artistsViewModel.setSearching(false) + artistsViewModel.setQuery("") + } + } else { + BaseToolbar( + scaffoldState, + extraActions = { + IconButton(onClick = { artistsViewModel.setSearching(true) }) { + Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + } + } + ) + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistsViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistsViewModel.kt index 11c087a3..4b210b48 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistsViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/artists/ArtistsViewModel.kt @@ -3,7 +3,11 @@ package me.vanpetegem.accentor.ui.artists import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations.map +import androidx.lifecycle.Transformations.switchMap import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.Normalizer import javax.inject.Inject import me.vanpetegem.accentor.data.artists.Artist import me.vanpetegem.accentor.data.artists.ArtistRepository @@ -14,4 +18,23 @@ class ArtistsViewModel @Inject constructor( private val artistRepository: ArtistRepository, ) : AndroidViewModel(application) { val allArtists: LiveData> = artistRepository.allArtists + + private val _searching = MutableLiveData(false) + val searching: LiveData = _searching + + private val _query = MutableLiveData("") + val query: LiveData = _query + + val filteredArtists: LiveData> = switchMap(allArtists) { artists -> + map(query) { query -> + if (query.equals("")) { + artists + } else { + artists.filter { a -> a.normalizedName.contains(Normalizer.normalize(query, Normalizer.Form.NFKD), ignoreCase = true) } + } + } + } + + fun setSearching(value: Boolean) { _searching.value = value } + fun setQuery(value: String) { _query.value = value } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index ae14ec30..93ba433e 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -4,28 +4,40 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.ContentAlpha import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.ListItem +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TopAppBar +import androidx.compose.material.contentColorFor import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.primarySurface import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,6 +50,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -60,9 +74,11 @@ import kotlinx.coroutines.launch import me.vanpetegem.accentor.R import me.vanpetegem.accentor.ui.AccentorTheme import me.vanpetegem.accentor.ui.albums.AlbumGrid +import me.vanpetegem.accentor.ui.albums.AlbumToolbar import me.vanpetegem.accentor.ui.albums.AlbumView import me.vanpetegem.accentor.ui.albums.AlbumViewDropdown import me.vanpetegem.accentor.ui.artists.ArtistGrid +import me.vanpetegem.accentor.ui.artists.ArtistToolbar import me.vanpetegem.accentor.ui.artists.ArtistView import me.vanpetegem.accentor.ui.home.Home import me.vanpetegem.accentor.ui.login.LoginActivity @@ -106,13 +122,24 @@ fun Content(mainViewModel: MainViewModel = viewModel()) { PlayerOverlay(navController) { NavHost(navController = navController, startDestination = "home") { composable("home") { Base(navController, mainViewModel) { Home(navController) } } - composable("artists") { Base(navController, mainViewModel) { ArtistGrid(navController) } } + composable("artists") { Base(navController, mainViewModel, toolbar = { ArtistToolbar(it) }) { ArtistGrid(navController) } } composable("artists/{artistId}", arguments = listOf(navArgument("artistId") { type = NavType.IntType })) { entry -> Base(navController, mainViewModel) { ArtistView(entry.arguments!!.getInt("artistId"), navController) } } - composable("albums") { Base(navController, mainViewModel) { AlbumGrid(navController) } } + composable("albums") { Base(navController, mainViewModel, toolbar = { AlbumToolbar(it) }) { AlbumGrid(navController) } } composable("albums/{albumId}", arguments = listOf(navArgument("albumId") { type = NavType.IntType })) { entry -> - Base(navController, mainViewModel, extraDropdownItems = { AlbumViewDropdown(entry.arguments!!.getInt("albumId"), navController, it) }) { + Base( + navController, + mainViewModel, + toolbar = { scaffoldState -> + BaseToolbar( + scaffoldState, + extraDropdownItems = { + AlbumViewDropdown(entry.arguments!!.getInt("albumId"), navController, it) + }, + ) + }, + ) { AlbumView(entry.arguments!!.getInt("albumId"), navController) } } @@ -123,10 +150,10 @@ fun Content(mainViewModel: MainViewModel = viewModel()) { @Composable fun Base( navController: NavController, - mainViewModel: MainViewModel = viewModel(), + mainViewModel: MainViewModel = hiltViewModel(), playerViewModel: PlayerViewModel = hiltViewModel(), - extraDropdownItems: @Composable ((() -> Unit) -> Unit)? = null, - mainContent: @Composable (() -> Unit) + toolbar: @Composable ((ScaffoldState) -> Unit) = { scaffoldState -> BaseToolbar(scaffoldState) }, + mainContent: @Composable (() -> Unit), ) { val scaffoldState = rememberScaffoldState() val scope = rememberCoroutineScope() @@ -155,45 +182,7 @@ fun Base( } }, drawerGesturesEnabled = !(isPlayerOpen ?: false), - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.app_name)) }, - navigationIcon = { - IconButton(onClick = { scope.launch { scaffoldState.drawerState.open() } }) { - Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.open_drawer)) - } - }, - actions = { - var expanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.height(40.dp).aspectRatio(1f).wrapContentSize(Alignment.TopStart)) { - IconButton(onClick = { expanded = true }) { - Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.open_menu)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if (extraDropdownItems != null) { - extraDropdownItems { expanded = false } - } - DropdownMenuItem( - onClick = { - mainViewModel.refresh() - expanded = false - } - ) { - Text(stringResource(R.string.action_refresh)) - } - DropdownMenuItem( - onClick = { - mainViewModel.logout() - expanded = false - } - ) { - Text(stringResource(R.string.action_sign_out)) - } - } - } - } - ) - }, + topBar = { toolbar(scaffoldState) }, ) { _ -> val isRefreshing by mainViewModel.isRefreshing.observeAsState() SwipeRefresh( @@ -205,6 +194,87 @@ fun Base( } } +@Composable +fun BaseToolbar( + scaffoldState: ScaffoldState, + mainViewModel: MainViewModel = hiltViewModel(), + extraActions: @Composable (() -> Unit)? = null, + extraDropdownItems: @Composable ((() -> Unit) -> Unit)? = null, +) { + val scope = rememberCoroutineScope() + TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + navigationIcon = { + IconButton(onClick = { scope.launch { scaffoldState.drawerState.open() } }) { + Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.open_drawer)) + } + }, + actions = { + extraActions?.invoke() + var expanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.height(40.dp).aspectRatio(1f).wrapContentSize(Alignment.TopStart)) { + IconButton(onClick = { expanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.open_menu)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (extraDropdownItems != null) { + extraDropdownItems { expanded = false } + } + DropdownMenuItem( + onClick = { + mainViewModel.refresh() + expanded = false + } + ) { + Text(stringResource(R.string.action_refresh)) + } + DropdownMenuItem( + onClick = { + mainViewModel.logout() + expanded = false + } + ) { + Text(stringResource(R.string.action_sign_out)) + } + } + } + } + ) +} + +@Composable +fun SearchToolbar(value: String, update: (String) -> Unit, exit: () -> Unit) { + val focusRequester = remember { FocusRequester() } + TopAppBar(contentPadding = PaddingValues(0.dp)) { + IconButton( + onClick = { exit() }, + modifier = Modifier.padding(start = 8.dp), + ) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.stop_searching)) + } + TextField( + value, + update, + singleLine = true, + placeholder = { + Text( + stringResource(R.string.search), + color = MaterialTheme.colors.contentColorFor(MaterialTheme.colors.primarySurface).copy(ContentAlpha.medium) + ) + }, + colors = TextFieldDefaults.textFieldColors( + backgroundColor = MaterialTheme.colors.primarySurface, + cursorColor = LocalContentColor.current.copy(LocalContentAlpha.current) + ), + modifier = Modifier.weight(1f).fillMaxHeight().focusRequester(focusRequester), + ) + } + LaunchedEffect(focusRequester) { + focusRequester.requestFocus() + } + BackHandler { exit() } +} + @Composable fun DrawerRow(title: String, selected: Boolean, icon: Int, onClick: () -> Unit) { val background = if (selected) MaterialTheme.colors.primary.copy(alpha = 0.12f) else Color.Transparent diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/util/FastScrollableGrid.kt b/app/src/main/java/me/vanpetegem/accentor/ui/util/FastScrollableGrid.kt index b97e10e1..8a78208a 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/util/FastScrollableGrid.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/util/FastScrollableGrid.kt @@ -42,7 +42,6 @@ import kotlinx.coroutines.launch @Composable fun ScrollBar( state: LazyListState, - scrollableSize: IntSize, width: Dp = 8.dp, minimumHeight: Dp = 48.dp, getSectionName: ((Int) -> String) @@ -55,14 +54,20 @@ fun ScrollBar( animationSpec = tween(duration), ) val color = MaterialTheme.colors.secondary - val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index - val totalItemsCount = state.layoutInfo.totalItemsCount val coroutineScope = rememberCoroutineScope() var scrollbarOffset by remember { mutableStateOf(0.dp) } + val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index if (alpha > 0.0f && firstVisibleElementIndex != null) { val sectionName = getSectionName(firstVisibleElementIndex) + + val totalItemsCount = state.layoutInfo.totalItemsCount + val itemHeight = state.layoutInfo.visibleItemsInfo[0].size + val totalHeight = itemHeight * totalItemsCount + val boxHeight = state.layoutInfo.viewportEndOffset + val currentPosition = firstVisibleElementIndex * itemHeight + state.firstVisibleItemScrollOffset val topDistance = maxOf(0.dp, scrollbarOffset - (minimumHeight / 2)) + if (dragging) { Surface( modifier = Modifier.height(minimumHeight).width(minimumHeight).offset(-width * 2, topDistance), @@ -79,19 +84,19 @@ fun ScrollBar( modifier = Modifier.fillMaxHeight().width(width * 2).draggable( orientation = Orientation.Vertical, state = rememberDraggableState { delta -> - val percentage = delta / scrollableSize.height - coroutineScope.launch { - state.scrollToItem(maxOf(0, firstVisibleElementIndex + (percentage * totalItemsCount).toInt()), 0) - } + val percentage = delta / boxHeight + val newPosition = maxOf(0f, currentPosition + percentage * totalHeight) + val newIndex = (newPosition / itemHeight).toInt() + val newOffset = (newPosition - (newIndex * itemHeight)).toInt() + coroutineScope.launch { state.scrollToItem(newIndex, newOffset) } }, onDragStarted = { _ -> dragging = true }, onDragStopped = { _ -> dragging = false }, ) ) { - val baseElementHeight = scrollableSize.height.toFloat() / state.layoutInfo.totalItemsCount - val scrollbarHeight = maxOf(state.layoutInfo.visibleItemsInfo.size * baseElementHeight, minimumHeight.toPx()) - val elementHeight = (scrollableSize.height.toFloat() - scrollbarHeight) / state.layoutInfo.totalItemsCount - val scrollbarOffsetY = firstVisibleElementIndex * elementHeight + val scrollbarHeight = maxOf(boxHeight * (boxHeight.toFloat() / totalHeight), minimumHeight.toPx()) + val scrollbarDiff = maxOf(0f, scrollbarHeight - boxHeight * (boxHeight.toFloat() / totalHeight)) + val scrollbarOffsetY = (currentPosition.toFloat() / totalHeight * boxHeight) - (scrollbarDiff * (currentPosition.toFloat() / totalHeight)) scrollbarOffset = (scrollbarOffsetY / 1.dp.toPx()).dp drawRoundRect( @@ -118,6 +123,8 @@ fun FastScrollableGrid(gridItems: List, getSectionName: (T) -> String, it ) { items(gridItems.size) { i -> itemView(gridItems[i]) } } - ScrollBar(listState, boxSize, getSectionName = { getSectionName(gridItems[it * cardsPerRow]) }) + if (gridItems.size / maxOf(cardsPerRow, 2) > 8) { + ScrollBar(listState, getSectionName = { getSectionName(gridItems[it * cardsPerRow]) }) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1dd51d7..925a6148 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,4 +62,6 @@ Tracks Go to album Go to %s + Search + Stop searching