QueryEditor

This commit is contained in:
tom5079
2024-03-02 15:55:47 -08:00
parent efc40ce458
commit 3f3774a0cd
2 changed files with 246 additions and 70 deletions

View File

@@ -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)
}
}
}

View File

@@ -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<EditableSearchQueryState> = emptyList()
): EditableSearchQueryState {
val queries = queries.toMutableStateList()
}
class Or(
queries: List<EditableSearchQueryState> = 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)
}
}
}