From 3f3774a0cd1147ff984794f65b2e92176f44efc7 Mon Sep 17 00:00:00 2001 From: tom5079 <7948651+tom5079@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:55:47 -0800 Subject: [PATCH] QueryEditor --- .../quaver/pupil/ui/composable/MainScreen.kt | 27 +- .../quaver/pupil/ui/composable/QueryEditor.kt | 289 ++++++++++++++---- 2 files changed, 246 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt index e0cb27cd..7942b85a 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainScreen.kt @@ -1,8 +1,6 @@ package xyz.quaver.pupil.ui.composable import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn @@ -31,10 +29,8 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.AddCircleOutline import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Face @@ -43,12 +39,9 @@ import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.Male -import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.Card -import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -80,11 +73,8 @@ import xyz.quaver.pupil.R import xyz.quaver.pupil.networking.GalleryInfo import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.networking.SearchQueryPreviewParameterProvider -import xyz.quaver.pupil.ui.theme.Blue300 import xyz.quaver.pupil.ui.theme.Blue600 -import xyz.quaver.pupil.ui.theme.Gray300 import xyz.quaver.pupil.ui.theme.Pink600 -import xyz.quaver.pupil.ui.theme.Red300 import xyz.quaver.pupil.ui.theme.Yellow400 import xyz.quaver.pupil.ui.viewmodel.MainUIState @@ -102,7 +92,7 @@ private val iconMap = mapOf( @Composable fun TagChipIcon(tag: SearchQuery.Tag) { - val icon = iconMap[tag.namespace ?: "tag"] + val icon = iconMap[tag.namespace] if (icon != null) Icon( @@ -122,9 +112,9 @@ fun TagChip( isFavorite: Boolean = false, enabled: Boolean = true, onClick: (SearchQuery.Tag) -> Unit = { }, - leftIcon: @Composable (SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) }, - rightIcon: @Composable (SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) }, - content: @Composable (SearchQuery.Tag) -> Unit = { tag -> Text(tag.tag) }, + leftIcon: @Composable (SearchQuery.Tag) -> Unit = { TagChipIcon(it) }, + rightIcon: @Composable (SearchQuery.Tag) -> Unit = { Spacer(Modifier.width(16.dp)) }, + content: @Composable (SearchQuery.Tag) -> Unit = { Text(it.tag) }, ) { val surfaceColor = if (isFavorite) Yellow400 else when (tag.namespace) { "male" -> Blue600 @@ -176,7 +166,7 @@ fun TagChip( @Preview @Composable fun QueryView( - @PreviewParameter(SearchQueryPreviewParameterProvider::class) query: SearchQuery?, + @PreviewParameter(SearchQueryPreviewParameterProvider::class) query: SearchQuery, topLevel: Boolean = true ) { val modifier = if (topLevel) { @@ -301,6 +291,8 @@ fun SearchBar( } } androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) { + val state = remember(query) { query.toEditableState() } + Column( Modifier .fillMaxSize() @@ -315,10 +307,7 @@ fun SearchBar( contentDescription = "close search bar" ) } - QueryEditor( - query = query, - onQueryChange = onQueryChange - ) + QueryEditor(state = state) } } } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt index 01e88f2b..ad3dc43c 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt @@ -5,10 +5,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -19,12 +21,14 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardColors import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -38,21 +42,154 @@ import androidx.compose.ui.unit.dp import xyz.quaver.pupil.R import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.ui.theme.Blue300 +import xyz.quaver.pupil.ui.theme.Blue600 import xyz.quaver.pupil.ui.theme.Gray300 +import xyz.quaver.pupil.ui.theme.Pink600 import xyz.quaver.pupil.ui.theme.Red300 +import xyz.quaver.pupil.ui.theme.Yellow400 + +private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) { + is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag) + is SearchQuery.And -> EditableSearchQueryState.And(queries.map { it.toEditableStateInternal() }) + is SearchQuery.Or -> EditableSearchQueryState.Or(queries.map { it.toEditableStateInternal() }) + is SearchQuery.Not -> EditableSearchQueryState.Not(query.toEditableStateInternal()) +} + +fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root + = EditableSearchQueryState.Root(this?.toEditableStateInternal()) + +private fun EditableSearchQueryState.toSearchQueryInternal(): SearchQuery? = when (this) { + is EditableSearchQueryState.Tag -> SearchQuery.Tag(namespace.value, tag.value) + is EditableSearchQueryState.And -> SearchQuery.And(queries.mapNotNull { it.toSearchQueryInternal() }) + is EditableSearchQueryState.Or -> SearchQuery.Or(queries.mapNotNull { it.toSearchQueryInternal() }) + is EditableSearchQueryState.Not -> query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) } +} + +fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery? + = query.value?.toSearchQueryInternal() + +fun coalesceTags(oldTag: EditableSearchQueryState.Tag?, newTag: EditableSearchQueryState?): EditableSearchQueryState? + = if (oldTag != null) { + when (newTag) { + is EditableSearchQueryState.Tag, + is EditableSearchQueryState.Not -> EditableSearchQueryState.And(listOf(oldTag, newTag)) + is EditableSearchQueryState.And -> newTag.apply { queries.add(oldTag) } + is EditableSearchQueryState.Or -> newTag.apply { queries.add(oldTag) } + null -> oldTag + } + } else newTag + +sealed interface EditableSearchQueryState { + class Tag( + namespace: String? = null, + tag: String = "" + ): EditableSearchQueryState { + val namespace = mutableStateOf(namespace) + val tag = mutableStateOf(tag) + } + + class And( + queries: List = emptyList() + ): EditableSearchQueryState { + val queries = queries.toMutableStateList() + } + + class Or( + queries: List = emptyList() + ): EditableSearchQueryState { + val queries = queries.toMutableStateList() + } + + class Not( + query: EditableSearchQueryState? = null + ): EditableSearchQueryState { + val query = mutableStateOf(query) + } + + class Root( + query: EditableSearchQueryState? = null + ) { + val query = mutableStateOf(query) + } + +} @Composable -fun NewQueryChip(currentQuery: SearchQuery) { +fun EditableTagChip( + state: EditableSearchQueryState.Tag, + isFavorite: Boolean = false, + enabled: Boolean = true, + leftIcon: @Composable (SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) }, + rightIcon: @Composable (SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) }, + content: @Composable (SearchQuery.Tag) -> Unit = { tag -> Text(tag.tag) }, +) { + val namespace by state.namespace + val tag by state.tag + + val surfaceColor = if (isFavorite) Yellow400 else when (namespace) { + "male" -> Blue600 + "female" -> Pink600 + else -> MaterialTheme.colorScheme.surface + } + + val contentColor = + if (surfaceColor == MaterialTheme.colorScheme.surface) + MaterialTheme.colorScheme.onSurface + else + Color.White + + val inner = @Composable { + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides MaterialTheme.typography.bodyMedium + ) { + val queryTag = SearchQuery.Tag(namespace, tag) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + leftIcon(queryTag) + content(queryTag) + rightIcon(queryTag) + } + } + } + + val modifier = Modifier.height(32.dp) + val shape = RoundedCornerShape(16.dp) + + if (enabled) + Surface( + modifier = modifier, + shape = shape, + color = surfaceColor, + content = inner + ) + else + Surface( + modifier, + shape = shape, + color = surfaceColor, + content = inner + ) +} + +@Composable +fun NewQueryChip( + currentQuery: EditableSearchQueryState?, + onNewQuery: (EditableSearchQueryState) -> Unit +) { var opened by remember { mutableStateOf(false) } @Composable fun NewQueryRow( + modifier: Modifier = Modifier, icon: ImageVector = Icons.Default.AddCircleOutline, text: String, onClick: () -> Unit ) { Row( - modifier = Modifier + modifier = modifier .height(32.dp) .clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically @@ -73,35 +210,42 @@ fun NewQueryChip(currentQuery: SearchQuery) { } Surface(shape = RoundedCornerShape(16.dp)) { - AnimatedContent(targetState = opened, label = "add new query") { targetOpened -> + AnimatedContent(targetState = opened, label = "add new query" ) { targetOpened -> if (targetOpened) { Column { NewQueryRow( + modifier = Modifier.fillMaxWidth(), icon = Icons.Default.RemoveCircleOutline, text = stringResource(android.R.string.cancel) ) { opened = false } HorizontalDivider() - NewQueryRow(text = stringResource(R.string.search_add_query_item_tag)) { - - } - if (currentQuery !is SearchQuery.And) { - HorizontalDivider() - NewQueryRow(text = "AND") { - + if (currentQuery !is EditableSearchQueryState.Tag) { + NewQueryRow(modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.search_add_query_item_tag)) { + opened = false + onNewQuery(EditableSearchQueryState.Tag()) } } - if (currentQuery !is SearchQuery.Or) { + if (currentQuery !is EditableSearchQueryState.And) { HorizontalDivider() - NewQueryRow(text = "OR") { - + NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "AND") { + opened = false + onNewQuery(EditableSearchQueryState.And()) } } - if (currentQuery !is SearchQuery.Not) { + if (currentQuery !is EditableSearchQueryState.Or) { HorizontalDivider() - NewQueryRow(text = "NOT") { - + NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "OR") { + opened = false + onNewQuery(EditableSearchQueryState.Or()) + } + } + if (currentQuery !is EditableSearchQueryState.Not || currentQuery.query.value != null) { + HorizontalDivider() + NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "NOT") { + opened = false + onNewQuery(EditableSearchQueryState.Not()) } } } @@ -116,26 +260,29 @@ fun NewQueryChip(currentQuery: SearchQuery) { @Composable fun QueryEditorQueryView( - query: SearchQuery?, - onQueryAdd: (SearchQuery) -> Unit + state: EditableSearchQueryState, + onQueryRemove: (EditableSearchQueryState) -> Unit, ) { - when (query) { - is SearchQuery.Tag -> { - TagChip( - query, + when (state) { + is EditableSearchQueryState.Tag -> { + EditableTagChip( + state, enabled = false, rightIcon = { Icon( modifier = Modifier .padding(8.dp) - .size(16.dp), + .size(16.dp) + .clickable { + onQueryRemove(state) + }, imageVector = Icons.Default.RemoveCircleOutline, contentDescription = stringResource(R.string.search_remove_query_item_description) ) } ) } - is SearchQuery.Or -> { + is EditableSearchQueryState.Or -> { Card( colors = CardColors( containerColor = Blue300, @@ -158,22 +305,27 @@ fun QueryEditorQueryView( ) { Text("OR", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium) Icon( - modifier = Modifier.size(16.dp), + modifier = Modifier + .size(16.dp) + .clickable { onQueryRemove(state) }, imageVector = Icons.Default.RemoveCircleOutline, - contentDescription = stringResource(xyz.quaver.pupil.R.string.search_remove_query_item_description) + contentDescription = stringResource(R.string.search_remove_query_item_description) ) } - query.queries.forEachIndexed { index, subquery -> + state.queries.forEachIndexed { index, subQueryState -> if (index != 0) { Text("+", modifier = Modifier.padding(horizontal = 8.dp)) } - QueryEditorQueryView(subquery, onQueryAdd) + QueryEditorQueryView( + subQueryState, + onQueryRemove = { state.queries.remove(it) } + ) } - NewQueryChip(query) { - + NewQueryChip(state) { newQueryState -> + state.queries.add(newQueryState) } } } } - is SearchQuery.And -> { + is EditableSearchQueryState.And -> { Card( colors = CardColors( containerColor = Gray300, @@ -196,21 +348,28 @@ fun QueryEditorQueryView( ) { Text("AND", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium) Icon( - modifier = Modifier.size(16.dp), + modifier = Modifier + .size(16.dp) + .clickable { onQueryRemove(state) }, imageVector = Icons.Default.RemoveCircleOutline, - contentDescription = stringResource(xyz.quaver.pupil.R.string.search_remove_query_item_description) + contentDescription = stringResource(R.string.search_remove_query_item_description) ) } - query.queries.forEach { subquery -> - QueryEditorQueryView(subquery, onQueryAdd) + state.queries.forEach { subQuery -> + QueryEditorQueryView( + subQuery, + onQueryRemove = { state.queries.remove(it) } + ) } - NewQueryChip(query) { - + NewQueryChip(state) { newQueryState -> + state.queries.add(newQueryState) } } } } - is SearchQuery.Not -> { + is EditableSearchQueryState.Not -> { + var subQueryState by state.query + Card( colors = CardColors( containerColor = Red300, @@ -233,12 +392,32 @@ fun QueryEditorQueryView( ) { Text("-", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium) Icon( - modifier = Modifier.size(16.dp), + modifier = Modifier + .size(16.dp) + .clickable { onQueryRemove(state) }, imageVector = Icons.Default.RemoveCircleOutline, - contentDescription = stringResource(xyz.quaver.pupil.R.string.search_remove_query_item_description) + contentDescription = stringResource(R.string.search_remove_query_item_description) ) } - QueryEditorQueryView(query.query, onQueryAdd) + val subQueryStateSnapshot = subQueryState + if (subQueryStateSnapshot != null) { + QueryEditorQueryView( + subQueryStateSnapshot, + onQueryRemove = { subQueryState = null } + ) + } + + if (subQueryStateSnapshot == null) { + NewQueryChip(state) { newQueryState -> + subQueryState = newQueryState + } + } + + if (subQueryStateSnapshot is EditableSearchQueryState.Tag) { + NewQueryChip(state) { newQueryState -> + subQueryState = coalesceTags(subQueryStateSnapshot, newQueryState) + } + } } } } @@ -247,19 +426,27 @@ fun QueryEditorQueryView( @Composable fun QueryEditor( - query: SearchQuery?, - onQueryChange: (SearchQuery) -> Unit, + state: EditableSearchQueryState.Root ) { + var rootQuery by state.query + Column( - modifier = Modifier.verticalScroll(rememberScrollState()) + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - if (query != null) { - QueryEditorQueryView(query = query) { - - } - } else { - NewQueryChip(null) { + val rootQuerySnapshot = rootQuery + if (rootQuerySnapshot != null) { + QueryEditorQueryView( + state = rootQuerySnapshot, + onQueryRemove = { rootQuery = null } + ) + } + if (rootQuerySnapshot is EditableSearchQueryState.Tag?) { + NewQueryChip(rootQuerySnapshot) { newState -> + rootQuery = coalesceTags(rootQuerySnapshot, newState) } } }