QueryEditor
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user