diff --git a/client/src/main/java/client/MyModule.java b/client/src/main/java/client/MyModule.java index 68ef84f..0afc5f9 100644 --- a/client/src/main/java/client/MyModule.java +++ b/client/src/main/java/client/MyModule.java @@ -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); diff --git a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java index 8fdc771..b260357 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -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 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()); diff --git a/client/src/main/java/client/scenes/SearchBarCtrl.java b/client/src/main/java/client/scenes/SearchBarCtrl.java new file mode 100644 index 0000000..4f6113b --- /dev/null +++ b/client/src/main/java/client/scenes/SearchBarCtrl.java @@ -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> onSearchCallback; + + /** + * Debounce timer to limit search frequency. + * + * @see SearchBarCtrl#initializeComponents() + */ + private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(SEARCH_DELAY_MS)); + + private Task> 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> 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 call() throws IOException, InterruptedException { + return serverUtils.getRecipesFiltered(filter); + } + }; + + currentSearchTask.setOnSucceeded(e -> { + List 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(); + }); + } +} diff --git a/client/src/main/java/client/utils/ServerUtils.java b/client/src/main/java/client/utils/ServerUtils.java index 1e7ee98..2d6dc46 100644 --- a/client/src/main/java/client/utils/ServerUtils.java +++ b/client/src/main/java/client/utils/ServerUtils.java @@ -50,6 +50,11 @@ public class ServerUtils { });// JSON string-> List (Jackson) } + public List 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 diff --git a/client/src/main/resources/client/scenes/FoodpalApplication.fxml b/client/src/main/resources/client/scenes/FoodpalApplication.fxml index 63779d6..5063d40 100644 --- a/client/src/main/resources/client/scenes/FoodpalApplication.fxml +++ b/client/src/main/resources/client/scenes/FoodpalApplication.fxml @@ -45,6 +45,8 @@ + + diff --git a/client/src/main/resources/client/scenes/SearchBar.fxml b/client/src/main/resources/client/scenes/SearchBar.fxml new file mode 100644 index 0000000..10e90fe --- /dev/null +++ b/client/src/main/resources/client/scenes/SearchBar.fxml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/main/resources/locale/lang_en.properties b/client/src/main/resources/locale/lang_en.properties index 874d231..1500a1a 100644 --- a/client/src/main/resources/locale/lang_en.properties +++ b/client/src/main/resources/locale/lang_en.properties @@ -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 \ No newline at end of file +lang.pl.display=Polski diff --git a/client/src/main/resources/locale/lang_nl.properties b/client/src/main/resources/locale/lang_nl.properties index 7067697..fad677d 100644 --- a/client/src/main/resources/locale/lang_nl.properties +++ b/client/src/main/resources/locale/lang_nl.properties @@ -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 diff --git a/client/src/main/resources/locale/lang_pl.properties b/client/src/main/resources/locale/lang_pl.properties index 28b1079..89da848 100644 --- a/client/src/main/resources/locale/lang_pl.properties +++ b/client/src/main/resources/locale/lang_pl.properties @@ -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 \ No newline at end of file +lang.pl.display=Polski