Merge branch 'feature/client-side-search' into 'main'

feat: Client side search box

Closes #34

See merge request cse1105/2025-2026/teams/csep-team-76!34
This commit is contained in:
Natalia Cholewa 2025-12-19 23:49:03 +01:00
commit 625982dcd2
9 changed files with 215 additions and 9 deletions

View file

@ -16,6 +16,7 @@
package client;
import client.scenes.FoodpalApplicationCtrl;
import client.scenes.SearchBarCtrl;
import client.scenes.recipe.IngredientListCtrl;
import client.scenes.recipe.RecipeStepListCtrl;
import client.utils.ConfigService;
@ -38,7 +39,7 @@ public class MyModule implements Module {
binder.bind(FoodpalApplicationCtrl.class).in(Scopes.SINGLETON);
binder.bind(IngredientListCtrl.class).in(Scopes.SINGLETON);
binder.bind(RecipeStepListCtrl.class).in(Scopes.SINGLETON);
binder.bind(SearchBarCtrl.class).in(Scopes.SINGLETON);
binder.bind(LocaleManager.class).in(Scopes.SINGLETON);
binder.bind(ServerUtils.class).in(Scopes.SINGLETON);
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);

View file

@ -41,6 +41,8 @@ public class FoodpalApplicationCtrl implements LocaleAware {
private final IngredientListCtrl ingredientListCtrl;
private final RecipeStepListCtrl stepListCtrl;
private SearchBarCtrl searchBarCtrl;
public VBox detailsScreen;
public HBox editableTitleArea;
@ -92,6 +94,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.localeManager = localeManager;
this.ingredientListCtrl = ingredientListCtrl;
this.stepListCtrl = stepListCtrl;
this.configService = configService;
initializeWebSocket();
@ -112,15 +115,38 @@ public class FoodpalApplicationCtrl implements LocaleAware {
updateFavouriteButton(newRecipe);
}
);
}
// Double-click to go to detail screen
recipeList.setOnMouseClicked(event -> {
final int DOUBLE_CLICK = 2; //to not get magic number:P
if (event.getClickCount() == DOUBLE_CLICK) {
openSelectedRecipe();
@Inject
void setSearchBarCtrl(SearchBarCtrl searchBarCtrl) {
this.searchBarCtrl = searchBarCtrl;
}
private void initializeSearchBar() {
// Refresh on search bar change
this.searchBarCtrl.setOnSearch(recipes -> {
// Don't lose selection on refresh
Recipe currentlySelected = recipeList.getSelectionModel().getSelectedItem();
int newIndex = -1;
if (recipes.contains(currentlySelected)) {
newIndex = recipes.indexOf(currentlySelected);
}
this.recipeList.getItems().setAll(recipes);
System.out.println("Search returned " + recipes.size() + " recipes.");
// Restore selection, if possible
if (newIndex != -1) {
this.recipeList.getSelectionModel().select(newIndex);
} else if (!recipes.isEmpty()) {
// Otherwise select first item
this.recipeList.getSelectionModel().selectFirst();
}
});
}
private void initializeWebSocket() {
webSocketUtils.connect(() -> {
webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> {
@ -179,6 +205,14 @@ public class FoodpalApplicationCtrl implements LocaleAware {
initStepsIngredientsList();
initRecipeList();
// Double-click to go to detail screen
recipeList.setOnMouseClicked(event -> {
final int DOUBLE_CLICK = 2; //to not get magic number:P
if (event.getClickCount() == DOUBLE_CLICK) {
openSelectedRecipe();
}
});
this.initializeSearchBar();
refresh();
updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem());
}
@ -223,7 +257,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
public void refresh() {
List<Recipe> recipes;
try {
recipes = server.getRecipes();
recipes = server.getRecipesFiltered(searchBarCtrl.getFilter());
} catch (IOException | InterruptedException e) {
recipes = Collections.emptyList();
System.err.println("Failed to load recipes: " + e.getMessage());

View file

@ -0,0 +1,150 @@
package client.scenes;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
import com.google.inject.Inject;
import commons.Recipe;
import javafx.animation.PauseTransition;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
import javafx.util.Duration;
import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
/**
* Controller for the search bar component.
* Manages the search input field and performs searches with debounce.
*/
public class SearchBarCtrl implements LocaleAware {
/**
* How many milliseconds to wait after the user's last keypress
* before sending an actual search request.
*/
private final static int SEARCH_DELAY_MS = 300;
private final LocaleManager localeManager;
private final ServerUtils serverUtils;
private Consumer<List<Recipe>> onSearchCallback;
/**
* Debounce timer to limit search frequency.
*
* @see SearchBarCtrl#initializeComponents()
*/
private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(SEARCH_DELAY_MS));
private Task<List<Recipe>> currentSearchTask = null;
@Inject
public SearchBarCtrl(LocaleManager localeManager, ServerUtils serverUtils) {
this.localeManager = localeManager;
this.serverUtils = serverUtils;
}
@FXML
private TextField searchField;
/**
* Set the callback function to be called when a search is performed.
*
* @param callback The callback function. Receives the list of recipes
* matching the search filter.
*/
public void setOnSearch(Consumer<List<Recipe>> callback) {
this.onSearchCallback = callback;
}
/**
* Get the current filter text - aka the text in the search field.
*
* @return The current filter text. "" if empty.
*/
public String getFilter() {
if (this.searchField == null || this.searchField.getText() == null) {
return "";
}
return this.searchField.getText();
}
/**
* Perform a search with the current filter text.
* If a previous search is still running, it will be cancelled.
*/
private void onSearch() {
if (this.onSearchCallback == null) {
return;
}
// Cancel any previously running search tasks.
if (currentSearchTask != null && currentSearchTask.isRunning()) {
currentSearchTask.cancel();
}
String filter = this.getFilter();
// Spawn a new background task. This allows us to continue
// to update the UI while waiting for the server to respond.
//
// Basically this just makes it feel less laggy and doesn't halt
// the entire application while searching.
currentSearchTask = new Task<>() {
@Override
protected List<Recipe> call() throws IOException, InterruptedException {
return serverUtils.getRecipesFiltered(filter);
}
};
currentSearchTask.setOnSucceeded(e -> {
List<Recipe> recipes = currentSearchTask.getValue();
this.onSearchCallback.accept(recipes);
});
// Don't do anything on failure - just log the error
currentSearchTask.setOnFailed(e -> {
System.err.println("Failed to fetch recipes: " + currentSearchTask.getException().getMessage());
});
// This could probably be done better with some sort
// of thread pool / executor service, but for now
// this *should* work fine, especially given the debounce time.
new Thread(currentSearchTask).start();
}
@Override
public void updateText() {
this.searchField.setPromptText(
this.getLocaleString("menu.search")
);
}
@Override
public LocaleManager getLocaleManager() {
return this.localeManager;
}
@Override
public void initializeComponents() {
// Set up the debounce for the search field.
//
// This makes it so that we only perform a search
// after the user has stopped typing for a short
// period of time, instead of on every single key press.
//
// This way, if a user is typing quickly, we don't
// spam the server with requests!
searchDebounce.setOnFinished(e -> {
this.onSearch();
});
this.searchField.setOnKeyReleased(event -> {
// This cancels the current debounce timer and restarts it.
this.searchDebounce.playFromStart();
});
}
}

View file

@ -50,6 +50,11 @@ public class ServerUtils {
});// JSON string-> List<Recipe> (Jackson)
}
public List<Recipe> getRecipesFiltered(String filter) throws IOException, InterruptedException {
// TODO: implement filtering on server side
return this.getRecipes();
}
/**
* Gets a single recipe based on its id.
* @param id every recipe has it's unique id

View file

@ -45,6 +45,8 @@
<Font name="System Bold" size="15.0" />
</font></Label>
<fx:include source="SearchBar.fxml" />
<ListView fx:id="recipeList" />
<HBox spacing="10">

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<HBox xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="client.scenes.SearchBarCtrl">
<children>
<TextField fx:id="searchField" promptText="Search..." />
</children>
</HBox>

View file

@ -25,6 +25,7 @@ menu.button.edit=Edit
menu.button.clone=Clone
menu.button.print=Print recipe
menu.search=Search...
lang.en.display=English
lang.nl.display=Nederlands
lang.pl.display=Polski
lang.pl.display=Polski

View file

@ -25,6 +25,7 @@ menu.button.edit=Bewerken
menu.button.clone=Dupliceren
menu.button.print=Recept afdrukken
menu.search=Zoeken...
lang.en.display=English
lang.nl.display=Nederlands
lang.pl.display=Polski

View file

@ -25,6 +25,7 @@ menu.button.edit=Edytuj
menu.button.clone=Duplikuj
menu.button.print=Drukuj przepis
menu.search=Szukaj...
lang.en.display=English
lang.nl.display=Nederlands
lang.pl.display=Polski
lang.pl.display=Polski