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 package xyz.quaver.pupil.ui.composable
import androidx.activity.compose.BackHandler 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.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn 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.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Book
import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Face 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.Group
import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.material.icons.filled.Male import androidx.compose.material.icons.filled.Male
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor 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.GalleryInfo
import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.networking.SearchQueryPreviewParameterProvider 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.Blue600
import xyz.quaver.pupil.ui.theme.Gray300
import xyz.quaver.pupil.ui.theme.Pink600 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.theme.Yellow400
import xyz.quaver.pupil.ui.viewmodel.MainUIState import xyz.quaver.pupil.ui.viewmodel.MainUIState
@@ -102,7 +92,7 @@ private val iconMap = mapOf(
@Composable @Composable
fun TagChipIcon(tag: SearchQuery.Tag) { fun TagChipIcon(tag: SearchQuery.Tag) {
val icon = iconMap[tag.namespace ?: "tag"] val icon = iconMap[tag.namespace]
if (icon != null) if (icon != null)
Icon( Icon(
@@ -122,9 +112,9 @@ fun TagChip(
isFavorite: Boolean = false, isFavorite: Boolean = false,
enabled: Boolean = true, enabled: Boolean = true,
onClick: (SearchQuery.Tag) -> Unit = { }, onClick: (SearchQuery.Tag) -> Unit = { },
leftIcon: @Composable (SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) }, leftIcon: @Composable (SearchQuery.Tag) -> Unit = { TagChipIcon(it) },
rightIcon: @Composable (SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) }, rightIcon: @Composable (SearchQuery.Tag) -> Unit = { Spacer(Modifier.width(16.dp)) },
content: @Composable (SearchQuery.Tag) -> Unit = { tag -> Text(tag.tag) }, content: @Composable (SearchQuery.Tag) -> Unit = { Text(it.tag) },
) { ) {
val surfaceColor = if (isFavorite) Yellow400 else when (tag.namespace) { val surfaceColor = if (isFavorite) Yellow400 else when (tag.namespace) {
"male" -> Blue600 "male" -> Blue600
@@ -176,7 +166,7 @@ fun TagChip(
@Preview @Preview
@Composable @Composable
fun QueryView( fun QueryView(
@PreviewParameter(SearchQueryPreviewParameterProvider::class) query: SearchQuery?, @PreviewParameter(SearchQueryPreviewParameterProvider::class) query: SearchQuery,
topLevel: Boolean = true topLevel: Boolean = true
) { ) {
val modifier = if (topLevel) { val modifier = if (topLevel) {
@@ -301,6 +291,8 @@ fun SearchBar(
} }
} }
androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) { androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) {
val state = remember(query) { query.toEditableState() }
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -315,10 +307,7 @@ fun SearchBar(
contentDescription = "close search bar" contentDescription = "close search bar"
) )
} }
QueryEditor( QueryEditor(state = state)
query = query,
onQueryChange = onQueryChange
)
} }
} }
} }

View File

@@ -5,10 +5,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -19,12 +21,14 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardColors import androidx.compose.material3.CardColors
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -38,21 +42,154 @@ import androidx.compose.ui.unit.dp
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.ui.theme.Blue300 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.Gray300
import xyz.quaver.pupil.ui.theme.Pink600
import xyz.quaver.pupil.ui.theme.Red300 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 @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) } var opened by remember { mutableStateOf(false) }
@Composable @Composable
fun NewQueryRow( fun NewQueryRow(
modifier: Modifier = Modifier,
icon: ImageVector = Icons.Default.AddCircleOutline, icon: ImageVector = Icons.Default.AddCircleOutline,
text: String, text: String,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = modifier
.height(32.dp) .height(32.dp)
.clickable(onClick = onClick), .clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -73,35 +210,42 @@ fun NewQueryChip(currentQuery: SearchQuery) {
} }
Surface(shape = RoundedCornerShape(16.dp)) { Surface(shape = RoundedCornerShape(16.dp)) {
AnimatedContent(targetState = opened, label = "add new query") { targetOpened -> AnimatedContent(targetState = opened, label = "add new query" ) { targetOpened ->
if (targetOpened) { if (targetOpened) {
Column { Column {
NewQueryRow( NewQueryRow(
modifier = Modifier.fillMaxWidth(),
icon = Icons.Default.RemoveCircleOutline, icon = Icons.Default.RemoveCircleOutline,
text = stringResource(android.R.string.cancel) text = stringResource(android.R.string.cancel)
) { ) {
opened = false opened = false
} }
HorizontalDivider() HorizontalDivider()
NewQueryRow(text = stringResource(R.string.search_add_query_item_tag)) { if (currentQuery !is EditableSearchQueryState.Tag) {
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.search_add_query_item_tag)) {
} opened = false
if (currentQuery !is SearchQuery.And) { onNewQuery(EditableSearchQueryState.Tag())
HorizontalDivider()
NewQueryRow(text = "AND") {
} }
} }
if (currentQuery !is SearchQuery.Or) { if (currentQuery !is EditableSearchQueryState.And) {
HorizontalDivider() 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() 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 @Composable
fun QueryEditorQueryView( fun QueryEditorQueryView(
query: SearchQuery?, state: EditableSearchQueryState,
onQueryAdd: (SearchQuery) -> Unit onQueryRemove: (EditableSearchQueryState) -> Unit,
) { ) {
when (query) { when (state) {
is SearchQuery.Tag -> { is EditableSearchQueryState.Tag -> {
TagChip( EditableTagChip(
query, state,
enabled = false, enabled = false,
rightIcon = { rightIcon = {
Icon( Icon(
modifier = Modifier modifier = Modifier
.padding(8.dp) .padding(8.dp)
.size(16.dp), .size(16.dp)
.clickable {
onQueryRemove(state)
},
imageVector = Icons.Default.RemoveCircleOutline, imageVector = Icons.Default.RemoveCircleOutline,
contentDescription = stringResource(R.string.search_remove_query_item_description) contentDescription = stringResource(R.string.search_remove_query_item_description)
) )
} }
) )
} }
is SearchQuery.Or -> { is EditableSearchQueryState.Or -> {
Card( Card(
colors = CardColors( colors = CardColors(
containerColor = Blue300, containerColor = Blue300,
@@ -158,22 +305,27 @@ fun QueryEditorQueryView(
) { ) {
Text("OR", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium) Text("OR", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium)
Icon( Icon(
modifier = Modifier.size(16.dp), modifier = Modifier
.size(16.dp)
.clickable { onQueryRemove(state) },
imageVector = Icons.Default.RemoveCircleOutline, 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)) } 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( Card(
colors = CardColors( colors = CardColors(
containerColor = Gray300, containerColor = Gray300,
@@ -196,21 +348,28 @@ fun QueryEditorQueryView(
) { ) {
Text("AND", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium) Text("AND", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium)
Icon( Icon(
modifier = Modifier.size(16.dp), modifier = Modifier
.size(16.dp)
.clickable { onQueryRemove(state) },
imageVector = Icons.Default.RemoveCircleOutline, 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 -> state.queries.forEach { subQuery ->
QueryEditorQueryView(subquery, onQueryAdd) 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( Card(
colors = CardColors( colors = CardColors(
containerColor = Red300, containerColor = Red300,
@@ -233,12 +392,32 @@ fun QueryEditorQueryView(
) { ) {
Text("-", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium) Text("-", modifier = Modifier.padding(horizontal = 8.dp), style = MaterialTheme.typography.labelMedium)
Icon( Icon(
modifier = Modifier.size(16.dp), modifier = Modifier
.size(16.dp)
.clickable { onQueryRemove(state) },
imageVector = Icons.Default.RemoveCircleOutline, 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 @Composable
fun QueryEditor( fun QueryEditor(
query: SearchQuery?, state: EditableSearchQueryState.Root
onQueryChange: (SearchQuery) -> Unit,
) { ) {
var rootQuery by state.query
Column( Column(
modifier = Modifier.verticalScroll(rememberScrollState()) modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (query != null) { val rootQuerySnapshot = rootQuery
QueryEditorQueryView(query = query) { if (rootQuerySnapshot != null) {
QueryEditorQueryView(
} state = rootQuerySnapshot,
} else { onQueryRemove = { rootQuery = null }
NewQueryChip(null) { )
}
if (rootQuerySnapshot is EditableSearchQueryState.Tag?) {
NewQueryChip(rootQuerySnapshot) { newState ->
rootQuery = coalesceTags(rootQuerySnapshot, newState)
} }
} }
} }