Compare commits

...

2 Commits

Author SHA1 Message Date
Wynd 357cab1afa Better filtering for both searching and tags 2025-09-08 14:04:33 +03:00
Wynd 67659a0af6 Add a demo for tag filtering, still heavily WIP 2025-09-08 01:48:54 +03:00
7 changed files with 179 additions and 18 deletions

View File

@ -12,18 +12,39 @@ 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 reloadTagFilterState() {
_tagFilters.value = _tagFilters.value
}
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
@ -31,18 +35,49 @@ 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 isSearchEmpty = search.value == null || search.value!!.isEmpty()
val hasTitle = entry.recipe.title.contains(search.value.orEmpty(), ignoreCase = true) val hasTitle = entry.recipe.title.contains(search.value.orEmpty(), ignoreCase = true)
val hasTags = entry.tags.isNotEmpty() && entry.tags.stream() val hasTags = 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
hasTitle || hasTags if (!isSearchEmpty && !hasTitle && !hasTags) {
return@isInSearch false
} }
val totalFilters = filters.value.stream().filter{ f -> f.checked }.count()
var checkedFilters = 0
for (filter in filters.value) {
if (filter.checked) {
val hasTagFilters = entry.tags.isNotEmpty() && entry.tags.stream()
.filter { tag -> tag.name.contains(filter.tag, ignoreCase = true) }
.count() > 0
if(hasTagFilters) {
checkedFilters += 1
}
else {
checkedFilters -= 1
}
}
}
val hasTagFilters = checkedFilters >= totalFilters
if (totalFilters > 0 && !hasTagFilters) {
return@isInSearch false
}
true
}
val openTagFilterDialog = remember { mutableStateOf(false) }
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "list" startDestination = "list"
@ -54,19 +89,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 +129,18 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
} }
} }
} }
when {
openTagFilterDialog.value -> {
TagFilterDialog(
onAccept = {
openTagFilterDialog.value = false
view.reloadTagFilterState()
},
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,81 @@
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 = { }) {
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")
}
}
}
}
}