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:
commit
625982dcd2
9 changed files with 215 additions and 9 deletions
|
|
@ -16,6 +16,7 @@
|
||||||
package client;
|
package client;
|
||||||
|
|
||||||
import client.scenes.FoodpalApplicationCtrl;
|
import client.scenes.FoodpalApplicationCtrl;
|
||||||
|
import client.scenes.SearchBarCtrl;
|
||||||
import client.scenes.recipe.IngredientListCtrl;
|
import client.scenes.recipe.IngredientListCtrl;
|
||||||
import client.scenes.recipe.RecipeStepListCtrl;
|
import client.scenes.recipe.RecipeStepListCtrl;
|
||||||
import client.utils.ConfigService;
|
import client.utils.ConfigService;
|
||||||
|
|
@ -38,7 +39,7 @@ public class MyModule implements Module {
|
||||||
binder.bind(FoodpalApplicationCtrl.class).in(Scopes.SINGLETON);
|
binder.bind(FoodpalApplicationCtrl.class).in(Scopes.SINGLETON);
|
||||||
binder.bind(IngredientListCtrl.class).in(Scopes.SINGLETON);
|
binder.bind(IngredientListCtrl.class).in(Scopes.SINGLETON);
|
||||||
binder.bind(RecipeStepListCtrl.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(LocaleManager.class).in(Scopes.SINGLETON);
|
||||||
binder.bind(ServerUtils.class).in(Scopes.SINGLETON);
|
binder.bind(ServerUtils.class).in(Scopes.SINGLETON);
|
||||||
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
|
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
||||||
private final IngredientListCtrl ingredientListCtrl;
|
private final IngredientListCtrl ingredientListCtrl;
|
||||||
private final RecipeStepListCtrl stepListCtrl;
|
private final RecipeStepListCtrl stepListCtrl;
|
||||||
|
|
||||||
|
private SearchBarCtrl searchBarCtrl;
|
||||||
|
|
||||||
public VBox detailsScreen;
|
public VBox detailsScreen;
|
||||||
public HBox editableTitleArea;
|
public HBox editableTitleArea;
|
||||||
|
|
||||||
|
|
@ -92,6 +94,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
||||||
this.localeManager = localeManager;
|
this.localeManager = localeManager;
|
||||||
this.ingredientListCtrl = ingredientListCtrl;
|
this.ingredientListCtrl = ingredientListCtrl;
|
||||||
this.stepListCtrl = stepListCtrl;
|
this.stepListCtrl = stepListCtrl;
|
||||||
|
|
||||||
this.configService = configService;
|
this.configService = configService;
|
||||||
|
|
||||||
initializeWebSocket();
|
initializeWebSocket();
|
||||||
|
|
@ -112,15 +115,38 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
||||||
updateFavouriteButton(newRecipe);
|
updateFavouriteButton(newRecipe);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Double-click to go to detail screen
|
@Inject
|
||||||
recipeList.setOnMouseClicked(event -> {
|
void setSearchBarCtrl(SearchBarCtrl searchBarCtrl) {
|
||||||
final int DOUBLE_CLICK = 2; //to not get magic number:P
|
this.searchBarCtrl = searchBarCtrl;
|
||||||
if (event.getClickCount() == DOUBLE_CLICK) {
|
}
|
||||||
openSelectedRecipe();
|
|
||||||
|
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() {
|
private void initializeWebSocket() {
|
||||||
webSocketUtils.connect(() -> {
|
webSocketUtils.connect(() -> {
|
||||||
webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> {
|
webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> {
|
||||||
|
|
@ -179,6 +205,14 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
||||||
initStepsIngredientsList();
|
initStepsIngredientsList();
|
||||||
initRecipeList();
|
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();
|
refresh();
|
||||||
updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem());
|
updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem());
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +257,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
||||||
public void refresh() {
|
public void refresh() {
|
||||||
List<Recipe> recipes;
|
List<Recipe> recipes;
|
||||||
try {
|
try {
|
||||||
recipes = server.getRecipes();
|
recipes = server.getRecipesFiltered(searchBarCtrl.getFilter());
|
||||||
} catch (IOException | InterruptedException e) {
|
} catch (IOException | InterruptedException e) {
|
||||||
recipes = Collections.emptyList();
|
recipes = Collections.emptyList();
|
||||||
System.err.println("Failed to load recipes: " + e.getMessage());
|
System.err.println("Failed to load recipes: " + e.getMessage());
|
||||||
|
|
|
||||||
150
client/src/main/java/client/scenes/SearchBarCtrl.java
Normal file
150
client/src/main/java/client/scenes/SearchBarCtrl.java
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,11 @@ public class ServerUtils {
|
||||||
});// JSON string-> List<Recipe> (Jackson)
|
});// 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.
|
* Gets a single recipe based on its id.
|
||||||
* @param id every recipe has it's unique id
|
* @param id every recipe has it's unique id
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@
|
||||||
<Font name="System Bold" size="15.0" />
|
<Font name="System Bold" size="15.0" />
|
||||||
</font></Label>
|
</font></Label>
|
||||||
|
|
||||||
|
<fx:include source="SearchBar.fxml" />
|
||||||
|
|
||||||
<ListView fx:id="recipeList" />
|
<ListView fx:id="recipeList" />
|
||||||
|
|
||||||
<HBox spacing="10">
|
<HBox spacing="10">
|
||||||
|
|
|
||||||
11
client/src/main/resources/client/scenes/SearchBar.fxml
Normal file
11
client/src/main/resources/client/scenes/SearchBar.fxml
Normal 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>
|
||||||
|
|
@ -25,6 +25,7 @@ menu.button.edit=Edit
|
||||||
menu.button.clone=Clone
|
menu.button.clone=Clone
|
||||||
menu.button.print=Print recipe
|
menu.button.print=Print recipe
|
||||||
|
|
||||||
|
menu.search=Search...
|
||||||
lang.en.display=English
|
lang.en.display=English
|
||||||
lang.nl.display=Nederlands
|
lang.nl.display=Nederlands
|
||||||
lang.pl.display=Polski
|
lang.pl.display=Polski
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ menu.button.edit=Bewerken
|
||||||
menu.button.clone=Dupliceren
|
menu.button.clone=Dupliceren
|
||||||
menu.button.print=Recept afdrukken
|
menu.button.print=Recept afdrukken
|
||||||
|
|
||||||
|
menu.search=Zoeken...
|
||||||
lang.en.display=English
|
lang.en.display=English
|
||||||
lang.nl.display=Nederlands
|
lang.nl.display=Nederlands
|
||||||
lang.pl.display=Polski
|
lang.pl.display=Polski
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ menu.button.edit=Edytuj
|
||||||
menu.button.clone=Duplikuj
|
menu.button.clone=Duplikuj
|
||||||
menu.button.print=Drukuj przepis
|
menu.button.print=Drukuj przepis
|
||||||
|
|
||||||
|
menu.search=Szukaj...
|
||||||
lang.en.display=English
|
lang.en.display=English
|
||||||
lang.nl.display=Nederlands
|
lang.nl.display=Nederlands
|
||||||
lang.pl.display=Polski
|
lang.pl.display=Polski
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue