Merge branch 'feature/client-recipe-i18n' into 'main'

Client recipe internationalization

Closes #39

See merge request cse1105/2025-2026/teams/csep-team-76!49
This commit is contained in:
Zhongheng Liu 2026-01-13 16:05:21 +01:00
commit adc79e2d71
14 changed files with 180 additions and 10 deletions

View file

@ -277,7 +277,10 @@ public class FoodpalApplicationCtrl implements LocaleAware {
public void refresh() { public void refresh() {
List<Recipe> recipes; List<Recipe> recipes;
try { try {
recipes = server.getRecipesFiltered(searchBarController.getFilter()); recipes = server.getRecipesFiltered(
searchBarController.getFilter(),
this.configService.getConfig().getRecipeLanguages()
);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
recipes = Collections.emptyList(); recipes = Collections.emptyList();
String msg = "Failed to load recipes: " + e.getMessage(); String msg = "Failed to load recipes: " + e.getMessage();

View file

@ -0,0 +1,87 @@
package client.scenes;
import client.utils.ConfigService;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import com.google.inject.Inject;
import javafx.fxml.FXML;
import javafx.scene.control.CheckMenuItem;
import javafx.scene.control.MenuButton;
import java.util.ArrayList;
import java.util.List;
public class LanguageFilterCtrl implements LocaleAware {
private final LocaleManager manager;
private final ConfigService configService;
private final FoodpalApplicationCtrl appCtrl;
@Inject
public LanguageFilterCtrl(LocaleManager manager, ConfigService configService, FoodpalApplicationCtrl appCtrl) {
this.manager = manager;
this.configService = configService;
this.appCtrl = appCtrl;
}
@FXML
MenuButton langFilterMenu;
List<String> selectedLanguages = new ArrayList<>();
private String getSelectedLanguagesDisplay() {
String joined = String.join(", ", selectedLanguages);
if (joined.isEmpty()) {
return "none";
} else {
return joined;
}
}
private void updateMenuButtonDisplay() {
langFilterMenu.setText(getLocaleString("menu.label.selected-langs") + ": " + this.getSelectedLanguagesDisplay());
}
@Override
public void updateText() {
this.updateMenuButtonDisplay();
}
@Override
public LocaleManager getLocaleManager() {
return this.manager;
}
public void initializeComponents() {
var items = this.langFilterMenu.getItems();
final List<String> languages = List.of("en", "nl", "pl");
this.selectedLanguages = this.configService.getConfig().getRecipeLanguages();
this.updateMenuButtonDisplay();
items.clear();
languages.forEach(lang -> {
CheckMenuItem it = new CheckMenuItem(lang);
if (selectedLanguages.contains(it.getText())) {
it.setSelected(true);
}
it.selectedProperty().addListener((observable, _, value) -> {
if (value) {
selectedLanguages.add(it.getText());
} else {
selectedLanguages.remove(it.getText());
}
configService.save();
selectedLanguages.sort(String::compareTo);
appCtrl.refresh();
this.updateMenuButtonDisplay();
});
items.add(it);
});
}
}

View file

@ -1,5 +1,6 @@
package client.scenes; package client.scenes;
import client.utils.ConfigService;
import client.utils.LocaleAware; import client.utils.LocaleAware;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.ServerUtils; import client.utils.ServerUtils;
@ -28,6 +29,7 @@ public class SearchBarCtrl implements LocaleAware {
private final LocaleManager localeManager; private final LocaleManager localeManager;
private final ServerUtils serverUtils; private final ServerUtils serverUtils;
private final ConfigService configService;
private Consumer<List<Recipe>> onSearchCallback; private Consumer<List<Recipe>> onSearchCallback;
@ -41,9 +43,10 @@ public class SearchBarCtrl implements LocaleAware {
private Task<List<Recipe>> currentSearchTask = null; private Task<List<Recipe>> currentSearchTask = null;
@Inject @Inject
public SearchBarCtrl(LocaleManager localeManager, ServerUtils serverUtils) { public SearchBarCtrl(LocaleManager localeManager, ServerUtils serverUtils, ConfigService configService) {
this.localeManager = localeManager; this.localeManager = localeManager;
this.serverUtils = serverUtils; this.serverUtils = serverUtils;
this.configService = configService;
} }
@FXML @FXML
@ -96,7 +99,7 @@ public class SearchBarCtrl implements LocaleAware {
currentSearchTask = new Task<>() { currentSearchTask = new Task<>() {
@Override @Override
protected List<Recipe> call() throws IOException, InterruptedException { protected List<Recipe> call() throws IOException, InterruptedException {
return serverUtils.getRecipesFiltered(filter); return serverUtils.getRecipesFiltered(filter, configService.getConfig().getRecipeLanguages());
} }
}; };

View file

@ -19,6 +19,7 @@ import javafx.scene.control.Button;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.ListView; import javafx.scene.control.ListView;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import javafx.scene.control.ComboBox;
import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
@ -77,6 +78,9 @@ public class RecipeDetailCtrl implements LocaleAware {
@FXML @FXML
private Button favouriteButton; private Button favouriteButton;
@FXML
private ComboBox<String> langSelector;
private ListView<Recipe> getParentRecipeList() { private ListView<Recipe> getParentRecipeList() {
return this.appCtrl.recipeList; return this.appCtrl.recipeList;
} }
@ -138,6 +142,7 @@ public class RecipeDetailCtrl implements LocaleAware {
this.showName(recipe.getName()); this.showName(recipe.getName());
this.ingredientListController.refetchFromRecipe(recipe); this.ingredientListController.refetchFromRecipe(recipe);
this.stepListController.refetchFromRecipe(recipe); this.stepListController.refetchFromRecipe(recipe);
this.langSelector.setValue(recipe.getLocale());
this.refreshFavouriteButton(); this.refreshFavouriteButton();
} }
@ -294,6 +299,20 @@ public class RecipeDetailCtrl implements LocaleAware {
editableTitleArea.getChildren().add(nameLabel); editableTitleArea.getChildren().add(nameLabel);
} }
/**
* Switch the recipe's language.
*/
@FXML
void changeLanguage() {
recipe.setLocale(this.langSelector.getValue());
try {
server.updateRecipe(this.recipe);
} catch (IOException | InterruptedException e) {
throw new UpdateException("Error occurred when updating recipe locale!");
}
}
@Override @Override
public void updateText() { public void updateText() {
editRecipeTitleButton.setText(getLocaleString("menu.button.edit")); editRecipeTitleButton.setText(getLocaleString("menu.button.edit"));
@ -309,5 +328,7 @@ public class RecipeDetailCtrl implements LocaleAware {
@Override @Override
public void initializeComponents() { public void initializeComponents() {
initStepsIngredientsList(); initStepsIngredientsList();
langSelector.getItems().addAll("en", "nl", "pl");
} }
} }

View file

@ -7,6 +7,7 @@ public class Config {
private String language = "en"; private String language = "en";
private List<String> recipeLanguages = new ArrayList<>();
private String serverUrl = "http://localhost:8080"; private String serverUrl = "http://localhost:8080";
private List<Long> favourites = new ArrayList<>(); private List<Long> favourites = new ArrayList<>();
@ -66,5 +67,28 @@ public class Config {
public void removeFavourite(long recipeId) { public void removeFavourite(long recipeId) {
getFavourites().remove(recipeId); getFavourites().remove(recipeId);
} }
/**
* Get a list of languages that should filter the displayed recipes.
*
* @return The desired languages the user would like to see.
*/
public List<String> getRecipeLanguages() {
return this.recipeLanguages;
}
/**
* Add a language to the list of filtering languages.
*/
public void addRecipeLanguage(String lang) {
this.recipeLanguages.add(lang);
}
/**
* Remove a language from the list of filtering languages.
*/
public void removeRecipeLanguage(String lang) {
this.recipeLanguages.remove(lang);
}
} }

View file

@ -40,9 +40,16 @@ public class ServerUtils {
* Gets all the recipes from the backend. * Gets all the recipes from the backend.
* @return a JSON string with all the recipes * @return a JSON string with all the recipes
*/ */
public List<Recipe> getRecipes() throws IOException, InterruptedException { public List<Recipe> getRecipes(List<String> locales) throws IOException, InterruptedException {
String uri =
SERVER +
"/recipes" +
"?locales=" +
String.join(",", locales);
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/recipes")) .uri(URI.create(uri))
.GET() .GET()
.build(); .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
@ -57,9 +64,9 @@ public class ServerUtils {
return list; // JSON string-> List<Recipe> (Jackson) return list; // JSON string-> List<Recipe> (Jackson)
} }
public List<Recipe> getRecipesFiltered(String filter) throws IOException, InterruptedException { public List<Recipe> getRecipesFiltered(String filter, List<String> locales) throws IOException, InterruptedException {
// TODO: implement filtering on server side // TODO: implement filtering on server side
return this.getRecipes(); return this.getRecipes(locales);
} }
/** /**
@ -88,7 +95,7 @@ public class ServerUtils {
*/ */
public Recipe addRecipe(Recipe newRecipe) throws IOException, InterruptedException { public Recipe addRecipe(Recipe newRecipe) throws IOException, InterruptedException {
//Make sure the name of the newRecipe is unique //Make sure the name of the newRecipe is unique
List<Recipe> allRecipes = getRecipes(); List<Recipe> allRecipes = getRecipes(List.of());
newRecipe.setId(null); // otherwise the id is the same as the original, and that's wrong newRecipe.setId(null); // otherwise the id is the same as the original, and that's wrong
// now that each recipeIngredient has its own ID in the database, // now that each recipeIngredient has its own ID in the database,
// we set that to null too to force a new persist value on the server // we set that to null too to force a new persist value on the server

View file

@ -48,6 +48,7 @@
</Label> </Label>
<fx:include source="SearchBar.fxml" fx:id="searchBar" /> <fx:include source="SearchBar.fxml" fx:id="searchBar" />
<fx:include source="LanguageFilter.fxml" fx:id="langFilter" />
<ListView fx:id="recipeList" /> <ListView fx:id="recipeList" />

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.MenuButton?>
<MenuButton
mnemonicParsing="false"
text="Languages"
xmlns:fx="http://javafx.com/fxml/1"
xmlns="http://javafx.com/javafx/25"
fx:controller="client.scenes.LanguageFilterCtrl"
fx:id="langFilterMenu"
/>

View file

@ -27,6 +27,8 @@
<Button fx:id="favouriteButton" onAction="#toggleFavourite" text="☆" /> <Button fx:id="favouriteButton" onAction="#toggleFavourite" text="☆" />
</HBox> </HBox>
<ComboBox fx:id="langSelector" onAction="#changeLanguage" />
<!-- Ingredients --> <!-- Ingredients -->
<fx:include source="IngredientList.fxml" fx:id="ingredientList" <fx:include source="IngredientList.fxml" fx:id="ingredientList"
VBox.vgrow="ALWAYS" maxWidth="Infinity" /> VBox.vgrow="ALWAYS" maxWidth="Infinity" />

View file

@ -25,6 +25,8 @@ menu.button.edit=Edit
menu.button.clone=Clone menu.button.clone=Clone
menu.button.print=Print recipe menu.button.print=Print recipe
menu.label.selected-langs=Languages
lang.en.display=English lang.en.display=English
lang.nl.display=Dutch lang.nl.display=Dutch
lang.pl.display=Polish lang.pl.display=Polish

View file

@ -26,6 +26,9 @@ menu.button.clone=Clone
menu.button.print=Print recipe menu.button.print=Print recipe
menu.search=Search... menu.search=Search...
menu.label.selected-langs=Languages
lang.en.display=English lang.en.display=English
lang.nl.display=Nederlands lang.nl.display=Nederlands
lang.pl.display=Polski lang.pl.display=Polski

View file

@ -25,6 +25,8 @@ menu.button.edit=Bewerken
menu.button.clone=Dupliceren menu.button.clone=Dupliceren
menu.button.print=Recept afdrukken menu.button.print=Recept afdrukken
menu.label.selected-langs=Talen
menu.search=Zoeken... menu.search=Zoeken...
lang.en.display=English lang.en.display=English
lang.nl.display=Nederlands lang.nl.display=Nederlands

View file

@ -26,6 +26,9 @@ menu.button.clone=Duplikuj
menu.button.print=Drukuj przepis menu.button.print=Drukuj przepis
menu.search=Szukaj... menu.search=Szukaj...
menu.label.selected-langs=J?zyki
lang.en.display=English lang.en.display=English
lang.nl.display=Nederlands lang.nl.display=Nederlands
lang.pl.display=Polski lang.pl.display=Polski

View file

@ -49,7 +49,7 @@ class ServerUtilsTest {
void tearDown() throws IOException, InterruptedException { void tearDown() throws IOException, InterruptedException {
// Not applicable in pipeline testing // Not applicable in pipeline testing
Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available"); Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available");
dv.getRecipes().stream().map(Recipe::getId).forEach(id -> { dv.getRecipes(List.of()).stream().map(Recipe::getId).forEach(id -> {
try { try {
dv.deleteRecipe(id); dv.deleteRecipe(id);
} catch (Exception ex) { } catch (Exception ex) {
@ -77,7 +77,7 @@ class ServerUtilsTest {
@Test @Test
void getAllRecipesTest() throws IOException, InterruptedException { void getAllRecipesTest() throws IOException, InterruptedException {
List<Recipe> recipes = dv.getRecipes(); List<Recipe> recipes = dv.getRecipes(List.of());
assertNotNull(recipes, "The list should not be null"); assertNotNull(recipes, "The list should not be null");
assertTrue(recipes.size() >= 0, "The list should be 0 (when no recipes), or more"); assertTrue(recipes.size() >= 0, "The list should be 0 (when no recipes), or more");