diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bf028c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +ArithTrainer/trainer.conf +**/users.data* +**/bin/ \ No newline at end of file diff --git a/ArithTrainer Tests/.classpath b/ArithTrainer Tests/.classpath new file mode 100644 index 0000000..fc87796 --- /dev/null +++ b/ArithTrainer Tests/.classpath @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ArithTrainer Tests/.project b/ArithTrainer Tests/.project new file mode 100644 index 0000000..b912e69 --- /dev/null +++ b/ArithTrainer Tests/.project @@ -0,0 +1,17 @@ + + + ArithTrainer Tests + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/ArithTrainer Tests/.settings/org.eclipse.jdt.core.prefs b/ArithTrainer Tests/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..3a21537 --- /dev/null +++ b/ArithTrainer Tests/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,11 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=1.8 +org.eclipse.jdt.core.compiler.debug.lineNumber=generate +org.eclipse.jdt.core.compiler.debug.localVariable=generate +org.eclipse.jdt.core.compiler.debug.sourceFile=generate +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.source=1.8 diff --git a/ArithTrainer Tests/test/me/despawningbone/arithtrain/AllTests.java b/ArithTrainer Tests/test/me/despawningbone/arithtrain/AllTests.java new file mode 100644 index 0000000..5ebbb0e --- /dev/null +++ b/ArithTrainer Tests/test/me/despawningbone/arithtrain/AllTests.java @@ -0,0 +1,11 @@ +package me.despawningbone.arithtrain; + +import org.junit.runner.RunWith; +import org.junit.runners.Suite; +import org.junit.runners.Suite.SuiteClasses; + +@RunWith(Suite.class) +@SuiteClasses({ ExpressionTest.class, UserTest.class }) +public class AllTests { + +} diff --git a/ArithTrainer Tests/test/me/despawningbone/arithtrain/ExpressionTest.java b/ArithTrainer Tests/test/me/despawningbone/arithtrain/ExpressionTest.java new file mode 100644 index 0000000..4428b8f --- /dev/null +++ b/ArithTrainer Tests/test/me/despawningbone/arithtrain/ExpressionTest.java @@ -0,0 +1,71 @@ +package me.despawningbone.arithtrain; + +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +public class ExpressionTest { + + @Test + public void testEvaluation() { //will not test stepEval since it is vulnerable by design and needs evaluateWithSteps as a linter + //normal cases + assertEquals(String.valueOf(4.0), ExpressionGenerator.evaluateWithSteps("2+2").get(1)); //normal operation + assertEquals(String.valueOf(2.5), ExpressionGenerator.evaluateWithSteps("2+2/4").get(2)); //priority + assertEquals(String.valueOf(1-(2-3/(4+5d))-2), ExpressionGenerator.evaluateWithSteps("1-(2-3/(4+5))-2").get(5)); //parentheses + assertEquals(String.valueOf(2.0E7), ExpressionGenerator.evaluateWithSteps("1E10 / 500").get(1)); //E notation + + assertEquals(Arrays.asList("5/(4%8)+8-34+43*2", "5/4.0+8-34+43*2" ,"1.25+8-34+43*2", "1.25+8-34+86.0", "9.25-34+86.0", "-24.75+86.0", "61.25"), ExpressionGenerator.evaluateWithSteps("5/(4%8)+8-34+43*2")); //steps + + //edge cases + assertEquals(String.valueOf(0.0), ExpressionGenerator.evaluateWithSteps("1 / 1E1000").get(1)); + assertEquals(String.valueOf(23.0), ExpressionGenerator.evaluateWithSteps("4 0+++2 8*- 1 1 % -1 2+-++-+--- 8 +- 1 ").get(5)); //space placements + positive/negative indicators + assertEquals(String.valueOf(210.0), ExpressionGenerator.evaluateWithSteps("7(((((5))))6)").get(2)); //redundant parentheses + assertEquals(Arrays.asList(""), ExpressionGenerator.evaluateWithSteps("")); //empty string + assertEquals(String.valueOf(-2323628.56), ExpressionGenerator.evaluateWithSteps("2374*-982+19005-11396+3044E-2").get(4)); //negative E notation + + //invalid cases + testEvaluationThrows(() -> ExpressionGenerator.evaluateWithSteps("32-(432+13/3/2")); //imbalanced parentheses + testEvaluationThrows(() -> ExpressionGenerator.evaluateWithSteps("!3a9-(231)-439-3")); //invalid characters + testEvaluationThrows(() -> ExpressionGenerator.evaluateWithSteps("\\39-(231)-439-3")); //invalid characters + testEvaluationThrows(() -> ExpressionGenerator.evaluateWithSteps("(1-1)%0")); //division by zero + testEvaluationThrows(() -> ExpressionGenerator.evaluateWithSteps("1/0")); //division by zero + testEvaluationThrows(() -> ExpressionGenerator.evaluateWithSteps("1E100 * 1E1000 - 1")); //infinity + testEvaluationThrows(() -> ExpressionGenerator.evaluateWithSteps(null)); //null + } + + private void testEvaluationThrows(Runnable run) { + try { + run.run(); + } catch(IllegalArgumentException e) { + return; + } + fail(); + } + + @Test + public void testGenerator() { + + Main.initOperators(); //need to manually invoke this since main class initConfig() is not invoked in test cases + + ExpressionGenerator exp1 = new ExpressionGenerator(19, 5, -1000, 19343); + + //normal cases + ExpressionGenerator exp2 = new ExpressionGenerator(10, 3, 0, 1000); + assertEquals(true, exp1.isHarder(exp2)); + + assertEquals("ExpGen{bracket=19,opCount=5,range=-1000-19343}", exp1.toString()); + + String exp = exp1.generate().get(0); + assertTrue(exp.length() - exp.replaceAll("(?<=[0-9)])[+\\-*/%]", "").length() == 5); //check how many actual operators are there, excluding negative indicators ("-"s not preceding numbers or parentheses, i.e. "+-" etc) + + //volume test + for(int i = 0; i < 10000; i++) { + List gen = exp1.generate(); + assertTrue(ExpressionGenerator.evaluateWithSteps(gen.get(0)).indexOf(gen.get(gen.size() - 1)) != -1); + } + } + +} diff --git a/ArithTrainer Tests/test/me/despawningbone/arithtrain/UserTest.java b/ArithTrainer Tests/test/me/despawningbone/arithtrain/UserTest.java new file mode 100644 index 0000000..58ac809 --- /dev/null +++ b/ArithTrainer Tests/test/me/despawningbone/arithtrain/UserTest.java @@ -0,0 +1,89 @@ +package me.despawningbone.arithtrain; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.NoSuchElementException; + +import org.junit.Test; + +import junit.framework.TestCase; + +public class UserTest extends TestCase { + + //no need to backup user.data since it will reside inside the test environment instead of usual locations that the trainer will be ran + + @Override + public void setUp() { + File pfile = new File(System.getProperty("user.dir") + File.separator + "users.data"); + try { + if (!pfile.createNewFile()) { + PrintWriter pw = new PrintWriter(pfile); //cleans the user database for test cases + pw.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + + Main.initLevels(); //have to init default config since actual config isnt loaded + } + + @Test + public void testUser() throws IOException { + //all registers will internally instantiate User, therefore no need to instantiate again to test if user exists + //normal cases + User user1 = User.register("te$t", "Te$t1", "Te$t1"); //ASCII symbols + + //edge cases + User user2 = User.register("te|st", "Test1", "Test1"); //delimiter escaping + User user3 = User.register("ĦħĦħĦħĦħ", "ĦħTt1", "ĦħTt1"); //unicode characters + + assertTrue(Files.readAllLines(new File(System.getProperty("user.dir") + File.separator + "users.data").toPath(), StandardCharsets.UTF_8).size() == 3); + + assertEquals("te|st", user2.getName()); //check if delimiter escape succeeded + assertEquals("ĦħĦħĦħĦħ", user3.getName()); //check if unicode can be get with getName correctly + + user1.updatePassword("Te$t1", "T$et1", "T$et1"); //uses same algorithm for matching passwords, so no need to double test + new User("te$t", "T$et1"); //check if updated by login + + assertEquals(1.0, user3.getAccuracyRaw()); + + for(int i = 0; i < 100; i++) { + user1.updateScore(true); + user2.updateScore(false); + } + assertEquals(2, user1.getLevel()); //according to default config, score 100 should be in level 2 + assertEquals(User.levels.floorEntry(100.0).getValue(), user1.getCurrentGenerator()); + assertEquals(100, user1.getScore()); + assertEquals(0.5, user1.getNextLevelPercent()); + assertEquals(0.0, user2.getAccuracy()); + + user2.save(); + assertTrue(Files.readAllLines(new File(System.getProperty("user.dir") + File.separator + "users.data").toPath(), StandardCharsets.UTF_8).get(1).endsWith("0|100")); //check if score update is reflected in database after save + + //invalid cases + testUserThrows(() -> User.register("test", "Test1", "test1")); //non matching passwords + testUserThrows(() -> User.register("te$t", "Te$t1", "Te$t1")); //registered user + testUserThrows(() -> User.register("test", "test1", "test1")); //not strong enough password + testUserThrows(() -> User.register(null, null, null)); //null + testUserThrows(() -> User.register("", "Test1", "Test1")); //empty username; empty password doesnt need to be checked since it will violate password strength check anyways + + testUserThrows(() -> new User("te$t", "Test1")); //wrong password + testUserThrows(() -> new User("test", "Test1")); //non-existent user + testUserThrows(() -> new User(null, null)); //null + + } + + private void testUserThrows(ModifRunnable run) throws IOException { + try { + run.run(); + } catch(IllegalArgumentException | NoSuchElementException e) { + return; + } + fail(); + } + + @FunctionalInterface public interface ModifRunnable { void run() throws IOException; } //so that i dont need to handle IOExceptions internally but pass it to JUnit +} diff --git a/ArithTrainer/.classpath b/ArithTrainer/.classpath new file mode 100644 index 0000000..cfabda2 --- /dev/null +++ b/ArithTrainer/.classpath @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ArithTrainer/.project b/ArithTrainer/.project new file mode 100644 index 0000000..8b390f3 --- /dev/null +++ b/ArithTrainer/.project @@ -0,0 +1,17 @@ + + + ArithTrainer + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/ArithTrainer/.settings/org.eclipse.jdt.core.prefs b/ArithTrainer/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..a698e59 --- /dev/null +++ b/ArithTrainer/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,12 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate +org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=1.8 +org.eclipse.jdt.core.compiler.debug.lineNumber=generate +org.eclipse.jdt.core.compiler.debug.localVariable=generate +org.eclipse.jdt.core.compiler.debug.sourceFile=generate +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.source=1.8 diff --git a/ArithTrainer/src/me/despawningbone/arithtrain/ExpressionGenerator.java b/ArithTrainer/src/me/despawningbone/arithtrain/ExpressionGenerator.java new file mode 100644 index 0000000..fcce0b5 --- /dev/null +++ b/ArithTrainer/src/me/despawningbone/arithtrain/ExpressionGenerator.java @@ -0,0 +1,205 @@ +package me.despawningbone.arithtrain; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.TreeMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The utility class for expression evaluation and generation.
+ * The generator can be instantiated with different configurable values, allowing for flexible expression generation.
+ * The evaluator can also be statically accessed in this class, since it does not have configurable values. + *

+ * 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 operators = new TreeMap<>(); + + public int bracketChance, operatorCount, origin, bound; + + /** + * Instantiates a generator with specifications according to the configuration passed in the parameters.
+ * + * @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 generate() { //generates only integers + ThreadLocalRandom ran = ThreadLocalRandom.current(); + int startingValue = ran.nextInt(origin, bound); //start it with a value so it wont be invalid + String exp = String.valueOf(startingValue); + for(int i = 0; i < operatorCount; i++) { + int opnd = ran.nextInt(origin, bound); + String operator = operators.floorEntry(ran.nextInt(100)).getValue(); //choose one randomly + if(ran.nextBoolean()) { //randomly choose to put in front or behind the old expression + exp += operator + opnd; + } else { + exp = opnd + operator + exp; + } + if(ran.nextInt(100) + 1 < bracketChance) { //add brackets + exp = "(" + exp + ")"; + } + } + + try { + List steps = stepEval(new ArrayList(Arrays.asList(exp))); //validate if whole expression is valid; might induce performance overhead but its the quickest way to do so + //no need evaluateWithSteps() coz the generated expression format can be directly parsed without reformatting + return steps; + } catch (IllegalArgumentException e) { + return generate(); //generate a new one until theres no divide by zero anymore + } + } + + /** + * Compares the current expression generator with another and determines if the current generator has strictly more difficult specifications than the other.
+ * The comparing criteria is as follows: + *

+ * All of the above must be true in order for the current generator to be considered harder.
+ * @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 evaluateWithSteps(String exp) { //wrapper for the stepEval function to make it easier to understand externally, and also acts as an expression linter + if(exp == null || Pattern.compile("[^+\\-*/%().0-9E ]").matcher(exp).find()) { //checking for anything other than the operators supported + throw new IllegalArgumentException("Invalid expression!"); + } + + Pattern pattern = Pattern.compile("\\(([0-9.E]*)\\)|\\((-*[0-9.E]*)\\)"); //remove unnecessary brackets, eg 1-(-6) -> 1--6 so splitting later on will not break + exp = exp.replaceAll("\\s", "") //DONE remove all spaces + .replaceAll("\\)([0-9(])", ")*$1").replaceAll("([0-9)])\\(", "$1*("); //add back *s between brackets so it wont break, but the steps will include it + Matcher match = pattern.matcher(exp); + while(match.find()) { //to remove multiple brackets if present, eg ((x)) + exp = match.replaceAll("$1$2"); + match = pattern.matcher(exp); + } + + return stepEval(new ArrayList(Arrays.asList(exp + .replaceAll("(? + *

+ * This method is not expected to be used alone! As a result, it has the private modifier to prevent outside access.
+ * 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 stepEval(List steps) { + String newestExp = steps.get(steps.size() - 1); + + if(newestExp.matches("-{0,1}[0-9.]*(?:E-{0,1})?[0-9.]*")) return steps; //stopping anchor; only case where - sign should exist is at the first char or after E for negative + + String[] splits = newestExp.split("(?<=[^0-9.E])(?=(?= 0) { + if(splits[index + 2].equals(")") && splits[index - 2].equals("(")) { //throws ArrayIndexOutOfBoundsException theres inbalanced parentheses + splits[index + 2] = ""; //wipes used parentheses + splits[index - 2] = ""; + } + } + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + throw new IllegalArgumentException("Invalid expression!"); //this should only throw if there is 2 operators at the same place, result reaches infinity or undefined, or if there is unfinished operators at the ends + } + + steps.add(String.join("", splits)); + return stepEval(steps); + } + +} diff --git a/ArithTrainer/src/me/despawningbone/arithtrain/Main.java b/ArithTrainer/src/me/despawningbone/arithtrain/Main.java new file mode 100644 index 0000000..629aefb --- /dev/null +++ b/ArithTrainer/src/me/despawningbone/arithtrain/Main.java @@ -0,0 +1,232 @@ +package me.despawningbone.arithtrain; + +import java.awt.GraphicsEnvironment; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Map.Entry; + +import javafx.animation.FadeTransition; +import javafx.animation.PauseTransition; +import javafx.application.Application; +import javafx.scene.image.Image; +import javafx.scene.layout.BorderPane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.stage.Stage; +import javafx.util.Duration; + +/** + * The initializing class for the arithmetic trainer. + * @author despawningbone + * + */ +public class Main extends Application { + /** + * Global precision value for answers accepted for the trainer mode. + */ + public static int precision = 2; + + public static void main(String[] args) { //no need to document this function in javadoc as it is the universal entry point of java programs + System.setProperty("prism.lcdtext", "false"); //fix anti aliasing + + initConfig(); + + File pfile = new File(System.getProperty("user.dir") + File.separator + "users.data"); + try { + if (pfile.createNewFile()) { + System.out.println("Cannot find user database, created a new one."); + } + } catch (IOException e) { + e.printStackTrace(); + } + + launch(args); + } + + /** + * Initializes the welcome menu and the UIHelper for subsequent GUI pages.
+ * 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 lowerGen = User.levels.floorEntry(req); + Entry higherGen = User.levels.ceilingEntry(req); + if((lowerGen == null || newGen.isHarder(lowerGen.getValue())) && (higherGen == null || higherGen.getValue().isHarder(newGen))) { //verify if the level makes sense with the requirement + User.levels.put(req, newGen); + } else { + throw new NumberFormatException(); + } + } catch (NumberFormatException e) { + System.out.println("Invalid levels! Using default..."); + initLevels(); + } + } + } + } + if(User.levels.isEmpty() || User.levels.firstKey() != 0) { + System.out.println("Invalid levels! Using default..."); + User.levels.clear(); + initLevels(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + /** + * Initializes the operator weights with default value. + */ + static void initOperators() { + int[] vals = {0, 35, 60, 77, 90}; + String[] ops = {"+", "-", "*", "/", "%"}; + for(int i = 0; i < 5; i++) { + ExpressionGenerator.operators.put(vals[i], ops[i]); + } + } + + /** + * Initializes the levels with default value. + */ + static void initLevels() { + User.levels.put((double) 0, new ExpressionGenerator(0, 3, 0 ,9)); //initialize levels + User.levels.put((double) 50, new ExpressionGenerator(10, 4, -10, 20)); + User.levels.put((double) 150, new ExpressionGenerator(20, 5, -20, 50)); + } +} diff --git a/ArithTrainer/src/me/despawningbone/arithtrain/UIHelper.java b/ArithTrainer/src/me/despawningbone/arithtrain/UIHelper.java new file mode 100644 index 0000000..5698e2a --- /dev/null +++ b/ArithTrainer/src/me/despawningbone/arithtrain/UIHelper.java @@ -0,0 +1,493 @@ +package me.despawningbone.arithtrain; + +import java.io.IOException; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import javafx.animation.PauseTransition; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; +import javafx.stage.Stage; +import javafx.util.Duration; +import me.despawningbone.arithtrain.ExpressionGenerator; +import me.despawningbone.arithtrain.Main; +import me.despawningbone.arithtrain.User; + + +/** + * The UIHelper class for providing GUI pages and elements.
+ * 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 gen = user.getCurrentGenerator().generate(); + Label expContent = new Label(gen.get(0)); + currentResult = df.format(Double.parseDouble(gen.get(gen.size() - 1))); + exp.getStyleClass().add("exp"); + exp.setMaxSize((double) (w / 2), (double) (h / 4)); + exp.setFocusTraversable(false); + expContent.setPrefSize((double) (w / 2), (double) (h / 4)); + String expStyle = "-fx-padding: 0.5em; -fx-font-weight: bold; -fx-text-fill: #fff; -fx-alignment: center;" + + "-fx-background-radius: 1.2em; -fx-background-color:linear-gradient(to bottom right, rgba(36, 224, 216, 0.6), rgba(62, 106, 169, 0.8));"; + expContent.setStyle(expStyle + " -fx-font-size: 3em;"); + exp.getChildren().add(expContent); + exp.setAlignment(Pos.CENTER); + HBox hB = new HBox(); + hB.setPrefSize((double) w, (double) (h / 10)); + hB.setPadding(new Insets(0.0D, 0.0D, 0.0D, (double) (w / 80))); // to offset the inflated textfield + TextField input = this.getTextField("Answer", (String) null, (double) (w / 2), (double) (h / 12)); + input.setPrefSize((double) w, (double) (h / 10)); + StackPane footer = this.getFooter((event) -> { + try { + user.save(); + } catch (IOException e) { + e.printStackTrace(); + } + + this.showMainMenu(); + }); + Label score = new Label("Current Score: " + user.getScore()); + score.setPadding(new Insets(0.0D, (double) (w / 50), 0.0D, 0.0D)); + score.setStyle("-fx-font-weight: bold; -fx-font-size: 2.5em; -fx-text-fill: #fff;"); + footer.getChildren().add(score); + StackPane.setAlignment(score, Pos.CENTER_RIGHT); + Button send = this.getPicButton("send", (event) -> { + int lv = Integer.parseInt(fromLevel.getText()); + System.out.println("Expression generated: " + expContent.getText() + "; Expected: " + currentResult + ", Answer:" + input.getText()); + int newLv = 0; + try { + if (!currentResult.equals(df.format(Double.parseDouble(input.getText())))) { //match with configured precision or preciser + throw new NumberFormatException(); //pass to exception handler + } + expContent.setText("Correct!"); + newLv = user.updateScore(true); + score.setText("Current Score: " + user.getScore()); + } catch (NumberFormatException e) { + newLv = user.updateScore(false); + expContent.setText("Incorrect! The answer was: " + currentResult); + expContent.setStyle(expStyle + " -fx-font-size: 2em;"); + } + + input.setText(""); + ((Button) event.getSource()).setDisable(true); //so people cant spam it when its in result mode; have to cast coz even tho send is definitely initialized it thinks it isnt + bar.setProgress(user.getNextLevelPercent()); + if (lv != newLv) { + fromLevel.setText(String.valueOf(newLv)); + toLevel.setText(String.valueOf(newLv + 1)); + if (lv < newLv) { + expContent.setText("Leveled up!"); + } else if (lv > newLv) { + expContent.setText("Oops! You dropped a level."); + } + } + wait.setOnFinished((e) -> { + ((Button) event.getSource()).setDisable(false); + List newGen = user.getCurrentGenerator().generate(); + expContent.setText(newGen.get(0)); + currentResult = df.format(Double.parseDouble(newGen.get(newGen.size() - 1))); + expContent.setStyle(expStyle + " -fx-font-size: 3em;"); + }); + wait.play(); + }, 0.1D, true); + hB.getChildren().add(input); + hB.getChildren().add(send); + hB.setSpacing((double) (w / 60)); + hB.setAlignment(Pos.CENTER); + center.getChildren().add(exp); + center.getChildren().add(hB); + center.setSpacing((double) (h / 12)); + center.setMaxSize((double) (w / 2), (double) (h / 2)); + center.setAlignment(Pos.CENTER); + top.getChildren().add(level); + top.getChildren().add(progress); + top.setSpacing((double) (w / 40)); + top.setAlignment(Pos.CENTER); + pane.setTop(top); + pane.setCenter(center); + pane.setBottom(footer); + this.display(pane, true); + } + + /** + * Shows the solver page. + */ + public void showSolver() { + BorderPane pane = new BorderPane(); + VBox vB = new VBox(); + vB.setSpacing((double) (h / 20)); + vB.setStyle("-fx-padding: 4em;"); + HBox hB = new HBox(); + hB.setPrefSize((double) w, (double) (h / 10)); + TextField input = this.getTextField("Enter your expression here", (String) null, (double) w * 0.79D, + (double) (h / 10)); + input.setPrefSize((double) w, (double) (h / 10)); + TextArea result = new TextArea(); + result.setEditable(false); //so no input is done in the steps field + result.setPrefSize((double) w, (double) h); + result.setFocusTraversable(false); + String resultStyle = "-fx-focus-color: transparent; -fx-faint-focus-color: transparent; -fx-border-color: transparent;-fx-padding: 0.5em; -fx-font-size: 1.5em; -fx-font-weight: 400;-fx-opacity: 0.8; -fx-background-radius: 1.2em; -fx-background-color:"; + result.setStyle(resultStyle + "rgba(255, 255, 255, 0.7)"); + Button send = this.getPicButton("send", (event) -> { + String steps; + try { + steps = String.join("\n= ", ExpressionGenerator.evaluateWithSteps(input.getText())); + result.setStyle(resultStyle + "rgba(255, 255, 255, 0.7)"); + } catch (IllegalArgumentException e) { + //shake the result box? + result.setStyle(resultStyle + "rgba(255, 146, 146, 0.7);"); + steps = e.getMessage(); + } + + result.setText(steps); + }, 0.1D, true); + hB.setSpacing((double) (w / 40)); + hB.getChildren().add(input); + hB.getChildren().add(send); + hB.setAlignment(Pos.CENTER); + vB.getChildren().add(hB); + vB.getChildren().add(result); + pane.setCenter(vB); + pane.setBottom(this.getFooter((EventHandler) null)); + this.display(pane, true); + } +} \ No newline at end of file diff --git a/ArithTrainer/src/me/despawningbone/arithtrain/User.java b/ArithTrainer/src/me/despawningbone/arithtrain/User.java new file mode 100644 index 0000000..33357c0 --- /dev/null +++ b/ArithTrainer/src/me/despawningbone/arithtrain/User.java @@ -0,0 +1,308 @@ +package me.despawningbone.arithtrain; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Iterator; +import java.util.List; +import java.util.Map.Entry; +import java.util.NoSuchElementException; +import java.util.Scanner; +import java.util.TreeMap; + + +/** + * The utility class for handling user account storage and operations, including score calculations and leveling.
+ * 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 levels = new TreeMap<>(); + + /** + * A static method for creating a user object upon register.
+ * 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. + *

+ * 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,
+ * 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 lines = Files.readAllLines(Paths.get(System.getProperty("user.dir") + File.separator + "users.data"), StandardCharsets.UTF_8); + for(int i = 0; i < lines.size(); i++) { + String[] split = lines.get(i).split("(?<=[^\\\\])\\|"); + if(split[0].equalsIgnoreCase(this.name.replaceAll("\\|", "\\\\|"))) { + lines.set(i, update); + Files.write(path, lines, StandardCharsets.UTF_8); + return; + } + } + throw new IllegalArgumentException("This user does not exist!"); + } + + /** + * Gets the current level of the user in integer form. + * @return the level + */ + public int getLevel() { + Iterator> iterator = levels.entrySet().iterator(); + int i = 1; + while(iterator.hasNext()) { + if(iterator.next().getValue() == this.gen) { + return i; + } + i++; + } + throw new IllegalStateException("Invalid level!"); //as score * acc will never go down to 0, and the lowest key is 0, reaching this means something has gone wrong + } + +} diff --git a/ArithTrainer/src/resources/GothamBook.ttf b/ArithTrainer/src/resources/GothamBook.ttf new file mode 100644 index 0000000..6cdde85 Binary files /dev/null and b/ArithTrainer/src/resources/GothamBook.ttf differ diff --git a/ArithTrainer/src/resources/back_hover.png b/ArithTrainer/src/resources/back_hover.png new file mode 100644 index 0000000..786efb5 Binary files /dev/null and b/ArithTrainer/src/resources/back_hover.png differ diff --git a/ArithTrainer/src/resources/back_normal.png b/ArithTrainer/src/resources/back_normal.png new file mode 100644 index 0000000..16b1bde Binary files /dev/null and b/ArithTrainer/src/resources/back_normal.png differ diff --git a/ArithTrainer/src/resources/back_pressed.png b/ArithTrainer/src/resources/back_pressed.png new file mode 100644 index 0000000..786efb5 Binary files /dev/null and b/ArithTrainer/src/resources/back_pressed.png differ diff --git a/ArithTrainer/src/resources/bg-darken.png b/ArithTrainer/src/resources/bg-darken.png new file mode 100644 index 0000000..70ab172 Binary files /dev/null and b/ArithTrainer/src/resources/bg-darken.png differ diff --git a/ArithTrainer/src/resources/bg.jpg b/ArithTrainer/src/resources/bg.jpg new file mode 100644 index 0000000..68af42b Binary files /dev/null and b/ArithTrainer/src/resources/bg.jpg differ diff --git a/ArithTrainer/src/resources/globe.png b/ArithTrainer/src/resources/globe.png new file mode 100644 index 0000000..027ad0a Binary files /dev/null and b/ArithTrainer/src/resources/globe.png differ diff --git a/ArithTrainer/src/resources/main.css b/ArithTrainer/src/resources/main.css new file mode 100644 index 0000000..a41a5ee --- /dev/null +++ b/ArithTrainer/src/resources/main.css @@ -0,0 +1,29 @@ +.root { + -fx-font-family: "Gotham Bold"; +} + +.text-area, .text-area .viewport, .text-area .content { + -fx-background-color: transparent; +} + +.exp .scroll-pane .content .text { + -fx-text-alignment: center; +} + +.progress-bar { + -fx-accent: #44E298; + -fx-control-inner-background: transparent; + -fx-text-box-border: transparent; + -fx-background-color: transparent; +} + +.progress-bar > .bar { + -fx-background-radius: 5em; + -fx-text-box-border: transparent; +} + +.progress-bar > .track { + -fx-text-box-border: transparent; + -fx-background-radius: 0.5em; + -fx-background-color: #00B0F0; +} \ No newline at end of file diff --git a/ArithTrainer/src/resources/pw.png b/ArithTrainer/src/resources/pw.png new file mode 100644 index 0000000..d296a99 Binary files /dev/null and b/ArithTrainer/src/resources/pw.png differ diff --git a/ArithTrainer/src/resources/send_hover.png b/ArithTrainer/src/resources/send_hover.png new file mode 100644 index 0000000..ef0aa4d Binary files /dev/null and b/ArithTrainer/src/resources/send_hover.png differ diff --git a/ArithTrainer/src/resources/send_normal.png b/ArithTrainer/src/resources/send_normal.png new file mode 100644 index 0000000..ac333e1 Binary files /dev/null and b/ArithTrainer/src/resources/send_normal.png differ diff --git a/ArithTrainer/src/resources/send_pressed.png b/ArithTrainer/src/resources/send_pressed.png new file mode 100644 index 0000000..82c24ee Binary files /dev/null and b/ArithTrainer/src/resources/send_pressed.png differ diff --git a/ArithTrainer/src/resources/solve.png b/ArithTrainer/src/resources/solve.png new file mode 100644 index 0000000..83182b1 Binary files /dev/null and b/ArithTrainer/src/resources/solve.png differ diff --git a/ArithTrainer/src/resources/solve_hover.png b/ArithTrainer/src/resources/solve_hover.png new file mode 100644 index 0000000..ab1b2c4 Binary files /dev/null and b/ArithTrainer/src/resources/solve_hover.png differ diff --git a/ArithTrainer/src/resources/solve_normal.png b/ArithTrainer/src/resources/solve_normal.png new file mode 100644 index 0000000..0f8ba6d Binary files /dev/null and b/ArithTrainer/src/resources/solve_normal.png differ diff --git a/ArithTrainer/src/resources/solve_pressed.png b/ArithTrainer/src/resources/solve_pressed.png new file mode 100644 index 0000000..71e8154 Binary files /dev/null and b/ArithTrainer/src/resources/solve_pressed.png differ diff --git a/ArithTrainer/src/resources/train.png b/ArithTrainer/src/resources/train.png new file mode 100644 index 0000000..829d6ac Binary files /dev/null and b/ArithTrainer/src/resources/train.png differ diff --git a/ArithTrainer/src/resources/train_hover.png b/ArithTrainer/src/resources/train_hover.png new file mode 100644 index 0000000..c93ad9d Binary files /dev/null and b/ArithTrainer/src/resources/train_hover.png differ diff --git a/ArithTrainer/src/resources/train_normal.png b/ArithTrainer/src/resources/train_normal.png new file mode 100644 index 0000000..42c4bb6 Binary files /dev/null and b/ArithTrainer/src/resources/train_normal.png differ diff --git a/ArithTrainer/src/resources/train_pressed.png b/ArithTrainer/src/resources/train_pressed.png new file mode 100644 index 0000000..4c4b525 Binary files /dev/null and b/ArithTrainer/src/resources/train_pressed.png differ diff --git a/ArithTrainer/src/resources/user.png b/ArithTrainer/src/resources/user.png new file mode 100644 index 0000000..ae0d207 Binary files /dev/null and b/ArithTrainer/src/resources/user.png differ diff --git a/flowcharts/ExpEval.png b/flowcharts/ExpEval.png new file mode 100644 index 0000000..ada35f8 Binary files /dev/null and b/flowcharts/ExpEval.png differ diff --git a/flowcharts/Main.png b/flowcharts/Main.png new file mode 100644 index 0000000..92e6a76 Binary files /dev/null and b/flowcharts/Main.png differ diff --git a/flowcharts/Optimized.png b/flowcharts/Optimized.png new file mode 100644 index 0000000..927fe77 Binary files /dev/null and b/flowcharts/Optimized.png differ