Add a demo for tag filtering, still heavily WIP

master
Wynd 2025-09-08 01:48:54 +03:00
parent e996254451
commit 67659a0af6
7 changed files with 166 additions and 17 deletions

View File

@ -12,18 +12,35 @@ class RecipesView : ViewModel() {
private val _recipes = MutableStateFlow<List<RecipeWithTags>>( arrayListOf() ) private val _recipes = MutableStateFlow<List<RecipeWithTags>>( arrayListOf() )
val recipes = _recipes.asStateFlow() val recipes = _recipes.asStateFlow()
private val _tagFilters = MutableStateFlow<List<TagFilter>>( arrayListOf() )
val tagFilters = _tagFilters.asStateFlow()
private val _search = MutableStateFlow<String?>(null) private val _search = MutableStateFlow<String?>(null)
val search = _search.asStateFlow() val search = _search.asStateFlow()
private val _keepScreenOn = MutableStateFlow<Boolean>(false) private val _keepScreenOn = MutableStateFlow<Boolean>(false)
val keepScreenOn = _keepScreenOn.asStateFlow() val keepScreenOn = _keepScreenOn.asStateFlow()
fun setTagFilterState(tag: String, state: Boolean) {
_tagFilters.value = _tagFilters.value.toMutableList().apply { replaceAll { it -> if (tag == it.tag) it.checked = state; it } }.toList()
}
fun setKeepScreenOn(flag: Boolean) { fun setKeepScreenOn(flag: Boolean) {
_keepScreenOn.update { flag } _keepScreenOn.update { flag }
} }
fun setRecipes(recipes: List<RecipeWithTags>) { fun setRecipes(recipes: List<RecipeWithTags>) {
_recipes.update { recipes } _recipes.update { recipes }
val filters = arrayListOf<TagFilter>()
_recipes.value.stream()
.filter { it.tags.isNotEmpty() }
.forEach {
it.tags.forEach {tag -> filters.add(TagFilter(tag.name))}
}
_tagFilters.update { filters.distinct() }
} }
fun removeRecipe(recipe: RecipeWithTags) { fun removeRecipe(recipe: RecipeWithTags) {

View File

@ -0,0 +1,4 @@
package xyz.pixelatedw.recipe.data
data class TagFilter(val tag: String, var checked: Boolean = false) {
}

View File

@ -3,13 +3,11 @@ package xyz.pixelatedw.recipe.ui.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -21,7 +19,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@Composable @Composable
fun RecipeDeletionDialog(onAccept: ( ) -> Unit, onDismiss: () -> Unit) { fun DeleteRecipeDialog(onAccept: ( ) -> Unit, onDismiss: () -> Unit) {
Dialog(onDismissRequest = { onDismiss() }) { Dialog(onDismissRequest = { onDismiss() }) {
Card( Card(
modifier = Modifier modifier = Modifier

View File

@ -9,15 +9,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -25,24 +29,48 @@ import androidx.navigation.compose.rememberNavController
import xyz.pixelatedw.recipe.MainActivity import xyz.pixelatedw.recipe.MainActivity
import xyz.pixelatedw.recipe.data.RecipeWithTags import xyz.pixelatedw.recipe.data.RecipeWithTags
import xyz.pixelatedw.recipe.data.RecipesView import xyz.pixelatedw.recipe.data.RecipesView
import xyz.pixelatedw.recipe.data.TagFilter
@Composable @Composable
fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) { fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
val recipes = view.recipes.collectAsState() val recipes = view.recipes.collectAsState()
val active = view.activeRecipe.collectAsState() val active = view.activeRecipe.collectAsState()
val search = view.search.collectAsState() val search = view.search.collectAsState()
val filters = view.tagFilters.collectAsState()
val navController = rememberNavController() val navController = rememberNavController()
val isInSearch = isInSearch@{ entry: RecipeWithTags -> val isInSearch = isInSearch@{ entry: RecipeWithTags ->
val hasTitle = entry.recipe.title.contains(search.value.orEmpty(), ignoreCase = true) val hasTitle = search.value != null && search.value!!.isNotEmpty() && entry.recipe.title.contains(search.value.orEmpty(), ignoreCase = true)
val hasTags = entry.tags.isNotEmpty() && entry.tags.stream() val hasTags = entry.tags.isNotEmpty() && entry.tags.stream()
.filter { tag -> tag.name.contains(search.value.orEmpty(), ignoreCase = true) } .filter { tag -> tag.name.contains(search.value.orEmpty(), ignoreCase = true) }
.count() > 0 .count() > 0
var hasAtLeastOneFilter = false
var hasTagFilters = false
for (filter in filters.value) {
if (filter.checked) {
hasAtLeastOneFilter = true
hasTagFilters = entry.tags.isNotEmpty() && entry.tags.stream()
.filter { tag -> tag.name.contains(filter.tag, ignoreCase = true) }
.count() > 0
if(hasTagFilters) {
break
}
}
}
// TODO Needs much better filtering
if (hasAtLeastOneFilter) {
return@isInSearch hasTagFilters
}
hasTitle || hasTags hasTitle || hasTags
} }
val openTagFilterDialog = remember { mutableStateOf(false) }
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "list" startDestination = "list"
@ -54,19 +82,31 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp), .padding(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
OutlinedTextField( OutlinedTextField(
modifier = Modifier.weight(0.7f),
value = search.value.orEmpty(), value = search.value.orEmpty(),
onValueChange = { search -> view.setSearch(search) }, onValueChange = { search -> view.setSearch(search) },
) )
Button( // Tags
IconButton(
modifier = Modifier.weight(0.15f),
onClick = { openTagFilterDialog.value = true },
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Toggle tags filtering menu"
)
}
// Load
IconButton(
modifier = Modifier.weight(0.15f),
onClick = { ctx.sourceChooser.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) }, onClick = { ctx.sourceChooser.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) },
) { ) {
Text( Icon(
text = "Load", imageVector = Icons.Default.Add,
maxLines = 1, contentDescription = "Load recipes from filesystem"
textAlign = TextAlign.Center,
) )
} }
} }
@ -82,6 +122,19 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
} }
} }
} }
when {
openTagFilterDialog.value -> {
TagFilterDialog(
onAccept = {
// TODO This calls twice and therefore updates the search twice too, also hella stupid updating logic
openTagFilterDialog.value = false
view.setTagFilterState("", true)
},
view,
)
}
}
} }
composable("info") { composable("info") {
RecipeInfo(ctx, view, navController, padding, active.value!!) RecipeInfo(ctx, view, navController, padding, active.value!!)

View File

@ -13,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -31,7 +30,6 @@ import xyz.pixelatedw.recipe.data.RecipeWithTags
import xyz.pixelatedw.recipe.data.RecipesView import xyz.pixelatedw.recipe.data.RecipesView
import xyz.pixelatedw.recipe.utils.parseMarkdown import xyz.pixelatedw.recipe.utils.parseMarkdown
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RecipeInfo( fun RecipeInfo(
ctx: MainActivity, ctx: MainActivity,
@ -124,7 +122,7 @@ fun RecipeInfo(
when { when {
openDeletionDialog.value -> { openDeletionDialog.value -> {
RecipeDeletionDialog( DeleteRecipeDialog(
onAccept = { onAccept = {
view.removeRecipe(active) view.removeRecipe(active)
ctx.db.recipeDao().delete(active.recipe) ctx.db.recipeDao().delete(active.recipe)

View File

@ -33,7 +33,7 @@ fun RecipePreview(entry: RecipeWithTags, previewUri: Bitmap?, onClick: () -> Uni
bitmap = previewUri.asImageBitmap(), bitmap = previewUri.asImageBitmap(),
contentDescription = "Recipe image", contentDescription = "Recipe image",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.size(256.dp).padding(top = 16.dp, bottom = 16.dp) modifier = Modifier.size(256.dp).padding(top = 16.dp, bottom = 16.dp),
) )
} }
} }

View File

@ -0,0 +1,79 @@
package xyz.pixelatedw.recipe.ui.components
import androidx.collection.arraySetOf
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import xyz.pixelatedw.recipe.data.RecipesView
import xyz.pixelatedw.recipe.data.TagFilter
@Composable
fun TagFilterDialog(onAccept: ( ) -> Unit, view: RecipesView) {
val filters = view.tagFilters.collectAsState()
Dialog(onDismissRequest = onAccept) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(text = "Filter by Tags", fontSize = 6.em, modifier = Modifier.padding(16.dp))
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.9f)
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.wrapContentSize(Alignment.TopStart)) {
items(filters.value) { tag ->
// TODO This doesn't really feel right lmao, but for now it works
val filterChecked = remember { mutableStateOf(tag.checked) }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = filterChecked.value, onCheckedChange = {
filterChecked.value = !tag.checked
view.setTagFilterState(tag.tag, !tag.checked)
})
Text(tag.tag)
}
}
}
Row(modifier = Modifier.fillMaxWidth().padding(end = 16.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onAccept) {
Text("Done")
}
}
}
}
}