+ * Intended for use in the main arithmetic trainer, but third-party programs may also utilize this class as an expression generation and evaluation library if needed. + * @author despawningbone + */ + +public class ExpressionGenerator { + + /** + * A weighted representation of the operators' weighted chance to appear in generation. + *
+ * Note: This is a universal value across all generators!
+ */
+ public final static TreeMap
+ * All of the above must be true in order for the current generator to be considered harder.
+ * This method is not expected to be used alone! As a result, it has the private modifier to prevent outside access.
+ * Third-party programs are not expected to utilize this method, but they should access this method through reflection if they have to edit the database manually.
+ * @param pw the password which should include both upper case and lower case letters, along with at least one number
+ * @param confirm a matching password for confirmation
+ * @return the hashed string in hexadecimal form
+ */
+ private static String makePassword(String pw, String confirm) {
+ if(!pw.matches("(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).*")) throw new IllegalArgumentException("The password entered is not strong enough!\nIt needs to have at least 1 lower case letter, 1 upper case letter and 1 number.");
+ if(pw.equals(confirm)) {
+ String salt = generateSalt(8); //TODO make salt length configurable?
+ return salt + "$" + hash(pw + salt);
+ } else {
+ throw new IllegalArgumentException("Passwords does not match!");
+ }
+ }
+
+ /**
+ * Compares the hashed password with the password provided.
+ * @param pw the plaintext password inputted
+ * @param hash the hashed password stored
+ * @return true if identical, false otherwise
+ */
+ private static boolean matchPassword(String pw, String hash) {
+ String[] split = hash.split("\\$");
+ return hash(pw + split[0]).equals(split[1]);
+ }
+
+ /**
+ * A helper method for applying MD5 hashing on any unicode passwords.
+ * @param pw the password to be hashed
+ * @return the hashed password in hexadecimal form
+ */
+ private static String hash(String pw) {
+ try {
+
+ return new BigInteger(1, MessageDigest.getInstance("MD5").digest(pw.getBytes("UTF-8"))).toString(16);
+ } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
+ e.printStackTrace(); //should not be possible to have an exception here
+ return null;
+ }
+ }
+
+ private static final char[] alphanumeric = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();
+
+ /**
+ * Generates a variable length alphanumeric salt for use in hashing along with {@link User#hash(String)}.
+ * @param saltLength the desired character length of the salt
+ * @return the salt
+ */
+ private static String generateSalt(int saltLength) {
+ SecureRandom ran = new SecureRandom();
+ char[] chars = new char[saltLength];
+ for(int i = 0; i < saltLength; i++) {
+ chars[i] = alphanumeric[ran.nextInt(62)];
+ }
+ return new String(chars);
+ }
+
+ /**
+ * Searches the user database and returns the user data.
+ * @param name the username of the user to be searched
+ * @return a string array containing the user account details
+ * @throws IOException if the user database is not accessible
+ */
+ private static String[] searchData(String name) throws IOException {
+ if(name == null) return null;
+ name = name.replaceAll("\\|", "\\\\|");
+ Scanner scanner = new Scanner(new File(System.getProperty("user.dir") + File.separator + "users.data"), "UTF-8");
+ while(scanner.hasNextLine()) { //linear search; not putting the file in memory with Files.readLines() because it can be really big
+ String entry = scanner.nextLine();
+ String[] split = entry.split("(?<=[^\\\\])\\|");
+ if(split[0].equalsIgnoreCase(name)) {
+ scanner.close();
+ return split;
+ }
+ }
+ scanner.close();
+ return null;
+ }
+
+ //END STATIC METHODS
+
+ private long score;
+ private long answered;
+ private String name;
+ private ExpressionGenerator gen;
+ private int level;
+
+ /**
+ * Attempts to log a user in, instantiating a {@link User} object on success.
+ * @param name the username of the user to be logged in
+ * @param pw the password of the user to be logged in
+ * @throws IOException if the user database is not accessible
+ */
+ public User(String name, String pw) throws IOException { //perform login
+ String[] userData = searchData(name);
+ if(userData == null) {
+ throw new NoSuchElementException("This user does not exist!");
+ }
+
+ if(!matchPassword(pw, userData[1])) throw new IllegalArgumentException("Wrong password!");
+
+ this.score = Long.parseLong(userData[2]);
+ this.answered = Long.parseLong(userData[3]);
+ this.name = name;
+ this.gen = levels.floorEntry(score * getAccuracyRaw()).getValue();
+ this.level = getLevel();
+ }
+
+ /**
+ * Gets the user's name.
+ * @return the username string
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Gets the display accuracy in percentage form.
+ * @return the display accuracy
+ */
+ public double getAccuracy() {
+ return getAccuracyRaw() * 100;
+ }
+
+ /**
+ * Computes the raw accuracy of the user (from 0-1).
+ * @return the raw accuracy
+ */
+ public double getAccuracyRaw() {
+ return answered == 0 ? 1 : ((double) score / answered);
+ }
+
+ /**
+ * Returns the correct answer count of the user.
+ * @return the user's score
+ */
+ public long getScore() {
+ return score;
+ }
+
+ /**
+ * Returns the {@link ExpressionGenerator} object of the level the user is currently in.
+ * @return the expression generator
+ */
+ public ExpressionGenerator getCurrentGenerator() {
+ return gen;
+ }
+
+ /**
+ * Computes the current percentage from reaching the next level,
+ *
+ * @param bracketChance chance for brackets to appear (value should be from 0-100)
+ * @param operatorCount how many operators the generated expression will have
+ * @param origin lower bound of the generated numbers in the expressions (inclusive)
+ * @param bound upper bound of the generated numbers in the expressions (inclusive)
+ */
+ public ExpressionGenerator(int bracketChance, int operatorCount, int origin, int bound) {
+ this.bracketChance = bracketChance;
+ this.operatorCount = operatorCount;
+ this.origin = origin;
+ this.bound = bound;
+ }
+
+
+ /**
+ * Generates a direct {@link ExpressionGenerator#stepEval(List)} parsable expression along with the steps needed to solve according to the generator object's specifications.
+ * Note that as real number operations can be extremely difficult to calculate mentally, the arithmetic trainer will only generate integers.
+ *
+ * @return a generated expression string
+ */
+ public List
+ * The comparing criteria is as follows:
+ *
+ *
+ * @param gen the generator to be compared with
+ * @return true if the current generator is harder, false otherwise
+ */
+ public boolean isHarder(ExpressionGenerator gen) {
+ return this.bracketChance >= gen.bracketChance && this.operatorCount >= gen.operatorCount && (this.bound - this.origin) > (gen.bound - gen.origin);
+ }
+
+
+
+ /**
+ * Returns a textual representation of the specifications defined in this generator object.
+ * @return an array-like representation of the generator
+ */
+ @Override
+ public String toString() { //allow people to get the config values via toString()
+ return "ExpGen{bracket=" + this.bracketChance + ",opCount=" + this.operatorCount + ",range=" + this.origin + "-" + this.bound + "}";
+ }
+
+
+ /**
+ * Lints and evaluates a string expression, and returns the evaluated result with steps.
+ *
+ * @param exp the expression to be evaluated
+ * @return an array storing the steps involved in evaluating the result
+ */
+ public static List
+ * It is strongly recommended that third-party programs should utilize {@link ExpressionGenerator#evaluateWithSteps(String)} instead of accessing this method through reflection.
+ * @param steps a list containing the expression(s)
+ * @return a list containing the expressions including the newly evaluated one
+ */
+ private static List
+ * See the parent method for more information.
+ * @param main the main window stage to operate on
+ */
+ @Override
+ public void start(Stage main) throws Exception {
+ Font.loadFont(Main.class.getClassLoader().getResourceAsStream("resources/GothamBook.ttf"), 10);
+ Font.loadFont(Main.class.getClassLoader().getResourceAsStream("resources/GothamBold.ttf"), 10);
+ GraphicsEnvironment g = GraphicsEnvironment.getLocalGraphicsEnvironment(); //resize according to screen
+ int height = g.getDefaultScreenDevice().getDisplayMode().getHeight() / 2;
+ int width = g.getDefaultScreenDevice().getDisplayMode().getWidth() / 2;
+ if(height <= width) width = height / 9 * 16; //bind width to height with 16:9 so that 4:3 and 21:9 wont break the program
+ else height = width / 16 * 9; //bind to height if height is longer than width to avoid going out of bounds
+
+ UIHelper helper = new UIHelper(main, width, height);
+ main.setTitle("Arithmetic Trainer");
+ main.setResizable(false); //to avoid issues with resizing; since the window is already proportional
+ main.getIcons().add(new Image("resources/globe.png"));
+
+ main.setHeight(height);
+ main.setWidth(width);
+
+ //welcome
+ BorderPane welcome = new BorderPane();
+ Text t = new Text("Welcome to the Arithmetic Trainer");
+ t.setStyle("-fx-font-size: 3em; -fx-font-weight: bold;");
+ t.setFill(Color.WHITE);
+ welcome.setStyle("-fx-background-image: url(\"resources/bg-darken.png\"); -fx-background-size: cover;");
+ welcome.setCenter(t);
+ helper.display(welcome, true);
+ main.show();
+
+ //main menu
+
+
+ //transition from welcome to main menu
+ PauseTransition pause = new PauseTransition(Duration.millis(800));
+ pause.setOnFinished(p -> {
+ FadeTransition ft = new FadeTransition(Duration.millis(1200), t);
+ ft.setFromValue(1.0);
+ ft.setToValue(0.0);
+ ft.play();
+ ft.setOnFinished(f -> helper.showMainMenu());
+ });
+ pause.play();
+ }
+
+
+ /**
+ * Initializes the configuration, parsing the values into respective variables and generating one if one is not present.
+ */
+ public static void initConfig() {
+ try {
+ File pfile = new File(System.getProperty("user.dir") + File.separator + "trainer.conf");
+ if (pfile.createNewFile()) { //create config
+ BufferedWriter writer = new BufferedWriter(new FileWriter(pfile));
+ writer.append("######################\n");
+ writer.append("# Arithmetic Trainer \n");
+ writer.append("# By despawningbone \n");
+ writer.append("# v1.0 \n");
+ writer.append("######################\n");
+ writer.append("\n");
+ writer.append("# At least how many decimal places should the precision be\n");
+ writer.append("# for the answers inputted to be accepted? (Integer only)\n");
+ writer.append("# This excludes answers that doesnt need to be rounded,\n");
+ writer.append("# And does not alter the results in evaluation mode.\n");
+ writer.append("Precision=2\n");
+ writer.append("\n");
+ writer.append("# What percentage will each operator appear?\n");
+ writer.append("# They should add up to 100.\n");
+ writer.append("# [+, -, *, /, %]\n");
+ writer.append("OperatorWeight=35, 25, 17, 13, 10\n");
+ writer.append("\n");
+ writer.append("# How many levels should there be?\n");
+ writer.append("# Each new line represent a level, with the top one being the first.\n");
+ writer.append("# [requirement, bracketChance, operatorCount, lowerRange, upperRange]\n");
+ writer.append("# where requirement = score * accuracy,\n");
+ writer.append("# bracketChance = chance for brackets to appear (0-100),\n");
+ writer.append("# operatorCount = how many operators the generated expression will have,\n");
+ writer.append("# lowerRange & upperRange = how large the operand range will be (lower - upper inclusive).\n");
+ writer.append("# only when the next level is harder and contains only Integer values (except for requirement) will it be valid.\n");
+ writer.append("# The first level must start from 0.\n");
+ writer.append("Levels:\n");
+ writer.append("0, 0, 3, 0, 9\n");
+ writer.append("50, 10, 4, -10, 20\n");
+ writer.append("150, 20, 5, -20, 50\n");
+ writer.close();
+ System.out.println("Cannot find configuration, created a new one."); //use debug?
+ initLevels();
+ initOperators();
+ } else { //parse config
+ boolean levelsConfig = false;
+ for(String line : Files.readAllLines(Paths.get(pfile.getPath()))) {
+ if(line.isEmpty() || line.startsWith("#")) { //either comments or new line
+ continue;
+ } else {
+ if(line.contains("=")) { //the 2 options
+ String[] split = line.split("=");
+ switch(split[0].toLowerCase()) {
+ case "precision":
+ try {
+ precision = Integer.parseInt(split[1].trim());
+ if(precision < 0) {
+ precision = 2;
+ throw new NumberFormatException(); //pass to exception handler
+ }
+ } catch (NumberFormatException e) {
+ System.out.println("Invalid precision value! Using default... (2 d.p.)");
+ }
+ break;
+ case "operatorweight":
+ try {
+ int total = 0;
+ String[] vals = split[1].split(", ?");
+ String[] ops = {"+", "-", "*", "/", "%"};
+ for(int i = 0; i < 5; i++) {
+ int weight = Integer.parseInt(vals[i]);
+ ExpressionGenerator.operators.put(total, ops[i]);
+ total += weight;
+ }
+ if(total != 100) {
+ throw new NumberFormatException();
+ }
+ } catch (NumberFormatException e) {
+ System.out.println("Invalid operator weights! Using default... (35, 25, 17, 13, 10)");
+ initOperators();
+ }
+ break;
+ }
+ } else if(line.equalsIgnoreCase("Levels:")) { //start of levels config section
+ levelsConfig = true;
+ } else if(levelsConfig) { //levels config section
+ try {
+ String[] vals = line.split(", ?");
+ double req = Double.parseDouble(vals[0]);
+ ExpressionGenerator newGen = new ExpressionGenerator(Integer.parseInt(vals[1]), Integer.parseInt(vals[2]), Integer.parseInt(vals[3]), Integer.parseInt(vals[4]));
+ Entry
+ * It has been designed so that multiple stages can utilize the UIHelper with multiple UIHelper instances.
+ *
+ * @author despawningbone
+ */
+@SuppressWarnings({"unchecked", "rawtypes"}) //had to do this to drastically reduce code lines; besides i wont actually use the events in the functions anyways
+public class UIHelper {
+ private Stage m;
+ private int h;
+ private int w;
+
+ /**
+ * Instantiates the UIHelper with the respective window stage, and its size.
+ * @param main the window stage the UIHelper should operate on
+ * @param width the width of the window stage, in pixels
+ * @param height the height of the window stage, in pixels
+ */
+ public UIHelper(Stage main, int width, int height) {
+ m = main;
+ h = height;
+ w = width;
+ }
+
+ /**
+ * Displays a page according to the node given, adding suitable uniformed styling in the process.
+ * @param node the page node to be displayed
+ * @param darkenBg whether the background should be darkened or not
+ */
+ public void display(Parent node, boolean darkenBg) {
+ Scene scene = new Scene(node, (double) w, (double) h);
+ scene.getStylesheets().add("resources/main.css");
+ node.setStyle("-fx-background-image: url(\"resources/bg" + (darkenBg ? "-darken.png" : ".jpg")
+ + "\"); -fx-background-size: cover;");
+ this.m.setScene(scene);
+ }
+
+ /**
+ * A helper method for retrieving the 2 main menu buttons.
+ * @param op the name of the button to be retrieved
+ * @return the button object
+ */
+ public Button getMenuButton(String op) { //wrapper
+ return this.getPicButton(op, (event) -> { //no need to unrelease it
+ if (op.equals("train")) {
+ this.showLogin(false);
+ } else if (op.equals("solve")) {
+ this.showSolver();
+ }
+
+ }, 0.2D, false);
+ }
+
+ /**
+ * Retrieves a generic PNG-based button from the program's resources directory, and assigns generic reaction listeners to the button.
+ * @param op the name of the button to be retrieved
+ * @param func the function to be executed on mouse press (or enter if the defaultButton is true)
+ * @param scaleFactor how large the button should appear
+ * @param defaultButton whether the button should be triggered on pressing enter anywhere on the GUI.
+ * @return the retrieved button
+ */
+ private Button getPicButton(String op, EventHandler func, double scaleFactor, boolean defaultButton) {
+ Button b = new Button();
+ b.setGraphic(this.getImage(op, "normal", scaleFactor));
+ b.setStyle("-fx-background-color: transparent;");
+ b.setOnMouseEntered((event) -> {
+ b.setGraphic(this.getImage(op, "hover", scaleFactor));
+ });
+ b.setOnDragEntered((event) -> {
+ b.setGraphic(this.getImage(op, "hover", scaleFactor));
+ });
+ b.setOnMouseExited((event) -> {
+ b.setGraphic(this.getImage(op, "normal", scaleFactor));
+ });
+ b.setOnDragExited((event) -> {
+ b.setGraphic(this.getImage(op, "normal", scaleFactor));
+ });
+ b.setOnMousePressed((event) -> {
+ b.setGraphic(this.getImage(op, "pressed", scaleFactor));
+ });
+ b.setOnMouseReleased(func);
+ b.setOnMouseClicked((event) -> {
+ b.setGraphic(this.getImage(op, "hover", scaleFactor));
+ });
+ if (defaultButton) {
+ b.setDefaultButton(true);
+ b.setOnAction(func);
+ }
+
+ return b;
+ }
+
+ /**
+ * Constructs a generic CSS-based button, and assigns generic reaction listeners to the button.
+ * @param name the name of the CSS button
+ * @param style the CSS styling for the main button
+ * @param pressedStyle the CSS styling for when the button is pressed
+ * @param hoverStyle the CSS styling for when the button is hovered
+ * @param func the function to be executed on mouse press (or enter if the defaultButton is true)
+ * @param defaultButton whether the button should be triggered on pressing enter anywhere on the GUI.
+ * @return the retrieved button
+ */
+ private Button getCSSButton(String name, String style, String pressedStyle, String hoverStyle, EventHandler func,
+ boolean defaultButton) {
+ Button b = new Button(name);
+ b.setStyle(style);
+ b.setOnMouseEntered((event) -> {
+ b.setStyle(hoverStyle);
+ });
+ b.setOnDragEntered((event) -> {
+ b.setStyle(hoverStyle);
+ });
+ b.setOnMouseExited((event) -> {
+ b.setStyle(style);
+ });
+ b.setOnDragExited((event) -> {
+ b.setStyle(style);
+ });
+ b.setOnMousePressed((event) -> {
+ b.setStyle(pressedStyle);
+ });
+ b.setOnMouseReleased(func);
+ b.setOnMouseClicked((event) -> {
+ b.setStyle(hoverStyle);
+ });
+ if (defaultButton) {
+ b.setDefaultButton(true);
+ b.setOnAction(func);
+ }
+
+ return b;
+ }
+
+ /**
+ * Gets an image from the program's resource directory with pre-defined names.
+ * @param op the name of the image
+ * @param type the type of the image (hover, normal, pressed etc)
+ * @param scaleFactor how large the image should appear
+ * @return the retrieved image
+ */
+ private ImageView getImage(String op, String type, double scaleFactor) {
+ ImageView img = new ImageView(new Image("resources/" + op + "_" + type + ".png"));
+ img.setFitHeight((double) h * scaleFactor);
+ img.setPreserveRatio(true);
+ return img;
+ }
+
+ /**
+ * Constructs a uniform footer for GUI pages that needs a back button.
+ * @param func a custom function to be executed on pressing the back button, or null for returning to the main menu.
+ * @return the footer as a StackPane
+ */
+ public StackPane getFooter(EventHandler func) {
+ StackPane footer = new StackPane();
+ Rectangle box = new Rectangle(0.0D, 0.0D, (double) w, (double) (h / 8)); //h isnt / 10 to get all of available space
+ box.setStyle("-fx-fill: rgba(0, 0, 0, 0.5);");
+ Button back = this.getPicButton("back", func == null ? (event) -> {
+ this.showMainMenu();
+ } : func, 0.1D, false);
+ back.setCancelButton(true);
+ back.setOnAction(func == null ? (event) -> {
+ this.showMainMenu();
+ } : func);
+ footer.getChildren().add(box);
+ footer.getChildren().add(back);
+ footer.setPrefSize((double) w, (double) (h / 10));
+ footer.setAlignment(Pos.CENTER_LEFT);
+ StackPane.setAlignment(footer, Pos.BOTTOM_CENTER);
+ return footer;
+ }
+
+ /**
+ * Constructs a text field with the icon specified, according to the size instructed.
+ * @param name the name of the text field, this will be shown when the text field is not focused
+ * @param img the icon name in the resources directory of the program
+ * @param width the specified field width
+ * @param height the specified field height
+ * @return the constructed TextField
+ */
+ public TextField getTextField(String name, String img, double width, double height) {
+ Object f = img != null && img.equals("pw") ? new PasswordField() : new TextField(); //exception for passwords
+ ((TextField) f).setPromptText(name);
+ ((TextField) f).setMaxSize(width, height);
+ if (img != null) {
+ ((TextField) f).setPadding(new Insets(0.0D, 0.0D, 0.0D, (double) (w / 30)));
+ }
+
+ ((TextField) f).setStyle("-fx-background-insets: -0.5em;"
+ + (img == null ? ""
+ : " -fx-background-image:url(\"resources/" + img
+ + ".png\"); -fx-background-size: 1.8em; -fx-background-repeat: no-repeat; -fx-background-position: left center;")
+ + "-fx-background-radius: 5em; -fx-background-color: #fff; -fx-font-size: 1.8em;");
+ return (TextField) f;
+ }
+
+ //END HELPER METHODS
+ //START PAGES
+
+ /**
+ * Shows the main menu page.
+ */
+ public void showMainMenu() {
+ VBox menu = new VBox();
+ menu.getChildren().add(this.getMenuButton("train"));
+ menu.getChildren().add(this.getMenuButton("solve"));
+ menu.setAlignment(Pos.CENTER);
+ this.display(menu, false);
+ }
+
+ /**
+ * Shows the login or the register page.
+ * @param reg true to show the register page, and false for the login page.
+ */
+ public void showLogin(boolean reg) {
+ BorderPane pane = new BorderPane();
+ VBox box = new VBox();
+ box.setStyle(
+ "-fx-background-color: linear-gradient(to bottom right, rgba(50, 120, 134, 0.7), rgba(53, 204, 147, 0.9)); -fx-background-radius: 1.5em; -fx-padding: 2em;");
+ box.setAlignment(Pos.CENTER);
+ box.setSpacing((double) (h / 20));
+ HBox header = new HBox();
+ header.setMaxSize((double) (w / 2), (double) (h / 10));
+ header.setAlignment(Pos.CENTER_LEFT);
+ header.setSpacing((double) (w / 60));
+ Text name = new Text(reg ? "Register" : "Sign In");
+ name.setStyle("-fx-font-weight: bold; -fx-font-size: 2.5em; -fx-fill: #fff");
+ name.setFont(Font.font((String) null, FontWeight.EXTRA_BOLD, 25.0D));
+ TextField user = this.getTextField("Username", "user", (double) (w / 3), (double) (h / 10));
+ TextField password = this.getTextField("Password", "pw", (double) (w / 3), (double) (h / 10));
+ TextField reEnter = this.getTextField("Confirm Password", "pw", (double) (w / 3), (double) (h / 10));
+ HBox buttons = new HBox();
+ Button signIn = this.getCSSButton(reg ? "Register" : "Sign In",
+ "-fx-background-color: rgba(20, 20, 20, 0.7); -fx-text-fill: #fff; -fx-background-radius: 0.5em;",
+ "-fx-background-color: rgba(20, 20, 20, 0.85); -fx-text-fill: #fff; -fx-background-radius: 0.5em;",
+ "-fx-background-color: rgba(20, 20, 20, 0.55); -fx-text-fill: #fff; -fx-background-radius: 0.5em;",
+ (event) -> {
+ try {
+ if (reg) {
+ this.showTrainer(User.register(user.getText(), password.getText(), reEnter.getText()));
+ } else {
+ this.showTrainer(new User(user.getText(), password.getText()));
+ }
+ } catch (IllegalArgumentException | NoSuchElementException | IOException e) {
+ Text error = new Text(e instanceof IOException ? "Something went wrong: " + e.getMessage()
+ : e.getMessage());
+ error.setStyle("-fx-font-size: 1em; -fx-fill: #d00");
+ error.setWrappingWidth((double) w / 3.5D);
+ if (header.getChildren().size() == 1) {
+ header.getChildren().add(error);
+ } else {
+ header.getChildren().set(1, error);
+ }
+ }
+
+ }, true);
+ signIn.setPrefSize((double) (w / 8), (double) (h / 15));
+ signIn.setMinSize((double) (w / 8), (double) (h / 15));
+ header.getChildren().add(name);
+ box.getChildren().add(header);
+ box.getChildren().add(user);
+ box.getChildren().add(password);
+ buttons.getChildren().add(signIn);
+ buttons.setAlignment(Pos.CENTER);
+ buttons.setSpacing((double) (w / 12));
+ if (!reg) {
+ Button regButton = this.getCSSButton("Create Account",
+ "-fx-background-color: transparent; -fx-text-fill: #fff;",
+ "-fx-background-color: transparent; -fx-text-fill: #aaa; -fx-underline: true",
+ "-fx-background-color: transparent; -fx-text-fill: #fff; -fx-underline: true", (event) -> {
+ this.showLogin(true);
+ }, false);
+ regButton.setPrefSize((double) (w / 8), (double) (h / 10));
+ buttons.getChildren().add(regButton);
+ } else {
+ box.getChildren().add(reEnter);
+ }
+
+ box.getChildren().add(buttons);
+ box.setMaxSize((double) (w / 2), (double) (h / 2));
+ pane.setCenter(box);
+ pane.setBottom(reg ? this.getFooter((event) -> {
+ this.showLogin(false);
+ }) : this.getFooter((EventHandler) null));
+ this.display(pane, true);
+ }
+
+
+ private String currentResult;
+ /**
+ * Shows the trainer page.
+ * @param user the logged in user that the trainer page should operate in context of
+ */
+ public void showTrainer(User user) {
+ BorderPane pane = new BorderPane();
+ pane.setPadding(new Insets((double) (h / 30), 0.0D, 0.0D, 0.0D));
+ HBox top = new HBox();
+ Text level = new Text("Level");
+ level.setStyle("-fx-font-weight: bold; -fx-font-size: 2em; -fx-fill: #fff");
+ StackPane progress = new StackPane();
+ progress.setMaxSize((double) w * 0.8D, (double) (h / 10));
+ ProgressBar bar = new ProgressBar();
+ bar.setProgress(user.getNextLevelPercent());
+ bar.setPrefSize((double) w * 0.8D, (double) (h / 10));
+ bar.layout();
+ int initLv = user.getLevel();
+ Label fromLevel = new Label(String.valueOf(initLv));
+ fromLevel.setPrefSize((double) (h / 10), (double) (h / 10));
+ Label toLevel = new Label(String.valueOf(initLv + 1));
+ toLevel.setMaxSize((double) (h / 10), (double) (h / 10));
+ progress.getChildren().add(bar);
+ progress.getChildren().add(fromLevel);
+ toLevel.setAlignment(Pos.CENTER);
+ toLevel.setStyle(
+ "-fx-background-radius: 5em; -fx-background-color: #fff; -fx-font-size: 2em; -fx-background-insets: 1em;");
+ fromLevel.setAlignment(Pos.CENTER);
+ fromLevel.setStyle(
+ "-fx-background-radius: 5em; -fx-background-color: #fff; -fx-font-size: 2em; -fx-background-insets: 1em;");
+ progress.getChildren().add(toLevel);
+ StackPane.setAlignment(fromLevel, Pos.CENTER_LEFT);
+ StackPane.setAlignment(toLevel, Pos.CENTER_RIGHT);
+ DecimalFormat df = new DecimalFormat("#" + (Main.precision > 0 ? "." + String.join("", Collections.nCopies(Main.precision, "#")) : ""));
+ df.setRoundingMode(RoundingMode.HALF_UP);
+ PauseTransition wait = new PauseTransition(Duration.seconds(1.0D));
+ VBox center = new VBox();
+ StackPane exp = new StackPane();
+ List
+ * Third-party programs are expected to utilize this class to interact with the user database instead of their own codes.
+ *
+ * @author despawningbone
+ */
+public class User { //TODO test whole class; make it not reflection accessible and encrypt the database to prevent cheating/password editing?
+
+ /**
+ * A map storing the levels in the form of {@link ExpressionGenerator} objects.
+ * This allows modular addition of levels from external sources, namely the configuration.
+ * A TreeMap is utilized due to the strong ordering nature of levels and the ease of setting level thresholds.
+ */
+ public static TreeMap
+ * Instantiating through {@link User#User(String, String)} should be done instead if the user already exists.
+ * @param name the username of the new user
+ * @param pw the password of the new user, see {@link User#makePassword(String, String)}
+ * @param confirm a matching password for confirmation
+ * @return a new user object
+ * @throws IOException if the user database is not accessible
+ */
+ public static User register(String name, String pw, String confirm) throws IOException {
+ if(name == null || name.isEmpty()) throw new IllegalArgumentException("The username cannot be empty!");
+ if(searchData(name) != null) throw new IllegalArgumentException("This username is already taken!");
+ File pfile = new File(System.getProperty("user.dir") + File.separator + "users.data");
+ FileOutputStream out = new FileOutputStream(pfile, true);
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
+ writer.write(name.replaceAll("\\|", "\\\\|").toLowerCase() + "|" //so that the delimiter is unique even if people use it in usernames; case insensitive
+ + makePassword(pw, confirm) + "|" //encrypt password with salted MD5; using $ as delimiter because hex strings will not contain it
+ + "0|" //score
+ + "0\n" ); //answered (to compute accuracy)
+ writer.close();
+ out.close();
+ return new User(name, pw); //redundant login; but still a good check
+ }
+
+ /**
+ * Generates a salted and hashed password with MD5 for storage in the database.
+ *
+ * and returns infinity if there is no level after the current one.
+ * @return the percentage
+ */
+ public double getNextLevelPercent() {
+ if(levels.ceilingKey(score * getAccuracyRaw()) != null) {
+ return (score * getAccuracyRaw() - levels.floorKey(score * getAccuracyRaw())) / (levels.ceilingKey(score * getAccuracyRaw()) - levels.floorKey(score * getAccuracyRaw())); //so its the actual range not the whole range
+ } else {
+ return Double.POSITIVE_INFINITY;
+ }
+ }
+
+ /**
+ * Saves the user object into the user database.
+ * @throws IOException if the user database is not accessible
+ */
+ public void save() throws IOException { //NOTE: SAVING IS NOT THREAD SAFE!
+ editData( this.name.replaceAll("\\|", "\\\\|").toLowerCase() + "|" //so that the delimiter is unique even if people use it in usernames; case insensitive
+ + searchData(this.name)[1] + "|" //encrypt password with salted MD5; using $ as delimiter because hex strings will not contain it
+ + this.score + "|" //score
+ + this.answered); //answered (to compute accuracy)
+ }
+
+ /**
+ * Updates the user's password.
+ * This function is currently unused in the main arithmetic trainer, however third-party programs are free to utilize this method.
+ * @param oldPw the original password of the user
+ * @param newPw the new password of the user, see {@link User#makePassword(String, String)}
+ * @param confirm a matching password for confirmation
+ * @throws IOException if the user database is not accessible
+ */
+ public void updatePassword(String oldPw, String newPw, String confirm) throws IOException {
+ String[] values = searchData(this.name); //searchData should never return null because this is a valid user object
+ if(matchPassword(oldPw, values[1])) {
+ values[1] = makePassword(newPw, confirm);
+ editData(String.join("|", values));
+ } else {
+ throw new IllegalArgumentException("Wrong password!");
+ }
+ }
+
+ /**
+ * Updates the user's score according to whether he answered correctly or not.
+ * @param success whether the user succeeded in answering the question correctly
+ * @return the level the user should be in after the update, can be higher, lower or unchanged.
+ */
+ public int updateScore(boolean success) { //update score based on success; also updates level if needed
+ if(success) score++;
+ answered++;
+ ExpressionGenerator newGen = levels.floorEntry(score * getAccuracyRaw()).getValue();
+ if(newGen != this.gen) {
+ this.gen = newGen;
+ this.level = getLevel();
+ }
+ return level; //tells back if the level changed; it can be lower or higher
+ }
+
+ /**
+ * An internal method for editing the user database directly, changing the entry to be updated according to the username provided in the update string.
+ * @param update the updated user entry for replacement
+ * @throws IOException if the user database is not accessible
+ */
+ private void editData(String update) throws IOException { //Files.readLines() is utilized because all methods need to put the file into memory anyways
+ Path path = Paths.get(System.getProperty("user.dir") + File.separator + "users.data");
+ List