/**
* Copyright (C) 2021-2022 Adrien Hopkins
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package sevenUnitsGUI;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Scanner;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import sevenUnits.ProgramInfo;
import sevenUnits.unit.BaseDimension;
import sevenUnits.unit.BaseUnit;
import sevenUnits.unit.BritishImperial;
import sevenUnits.unit.LinearUnit;
import sevenUnits.unit.LinearUnitValue;
import sevenUnits.unit.Metric;
import sevenUnits.unit.Unit;
import sevenUnits.unit.UnitDatabase;
import sevenUnits.unit.UnitPrefix;
import sevenUnits.unit.UnitType;
import sevenUnits.unit.UnitValue;
import sevenUnits.utils.Nameable;
import sevenUnits.utils.ObjectProduct;
import sevenUnits.utils.UncertainDouble;
/**
* An object that handles interactions between the view and the backend code
*
* @author Adrien Hopkins
* @since 2021-12-15
*/
public final class Presenter {
/** The default place where settings are stored. */
private static final Path DEFAULT_SETTINGS_FILEPATH = Path
.of("settings.txt");
/** The default place where units are stored. */
private static final String DEFAULT_UNITS_FILEPATH = "/unitsfile.txt";
/** The default place where dimensions are stored. */
private static final String DEFAULT_DIMENSIONS_FILEPATH = "/dimensionfile.txt";
/** The default place where exceptions are stored. */
private static final String DEFAULT_EXCEPTIONS_FILEPATH = "/metric_exceptions.txt";
/**
* Adds default units and dimensions to a database.
*
* @param database database to add to
* @since 2019-04-14
* @since v0.2.0
*/
private static void addDefaults(final UnitDatabase database) {
database.addUnit("metre", Metric.METRE);
database.addUnit("kilogram", Metric.KILOGRAM);
database.addUnit("gram", Metric.KILOGRAM.dividedBy(1000));
database.addUnit("second", Metric.SECOND);
database.addUnit("ampere", Metric.AMPERE);
database.addUnit("kelvin", Metric.KELVIN);
database.addUnit("mole", Metric.MOLE);
database.addUnit("candela", Metric.CANDELA);
database.addUnit("bit", Metric.BIT);
database.addUnit("unit", Metric.ONE);
// nonlinear units - must be loaded manually
database.addUnit("tempCelsius", Metric.CELSIUS);
database.addUnit("tempFahrenheit", BritishImperial.FAHRENHEIT);
// load initial dimensions
database.addDimension("LENGTH", Metric.Dimensions.LENGTH);
database.addDimension("MASS", Metric.Dimensions.MASS);
database.addDimension("TIME", Metric.Dimensions.TIME);
database.addDimension("TEMPERATURE", Metric.Dimensions.TEMPERATURE);
}
/**
* @return text in About file
* @since 2022-02-19
*/
public static final String getAboutText() {
return Presenter.getLinesFromResource("/about.txt").stream()
.map(Presenter::withoutComments).collect(Collectors.joining("\n"))
.replaceAll("\\[VERSION\\]", ProgramInfo.VERSION.toString());
}
/**
* Gets the text of a resource file as a set of strings (each one is one line
* of the text).
*
* @param filename filename to get resource from
* @return contents of file
* @since 2021-03-27
*/
private static final List getLinesFromResource(String filename) {
final List lines = new ArrayList<>();
try (InputStream stream = inputStream(filename);
Scanner scanner = new Scanner(stream)) {
while (scanner.hasNextLine()) {
lines.add(scanner.nextLine());
}
} catch (final IOException e) {
throw new AssertionError(
"Error occurred while loading file " + filename, e);
}
return lines;
}
/**
* Gets an input stream for a resource file.
*
* @param filepath file to use as resource
* @return obtained Path
* @since 2021-03-27
*/
private static final InputStream inputStream(String filepath) {
return Presenter.class.getResourceAsStream(filepath);
}
/**
* @return {@code line} with any comments removed.
* @since 2021-03-13
*/
private static final String withoutComments(String line) {
final int index = line.indexOf('#');
return index == -1 ? line : line.substring(index);
}
// ====== SETTINGS ======
/**
* The view that this presenter communicates with
*/
private final View view;
/**
* The database that this presenter communicates with (effectively the model)
*/
final UnitDatabase database;
/**
* The rule used for parsing input numbers. Any number-string inputted into
* this program will be parsed using this method. Not implemented yet.
*/
private Function numberParsingRule;
/**
* The rule used for displaying the results of unit conversions. The result
* of unit conversions will be put into this function, and the resulting
* string will be used in the output.
*/
private Function numberDisplayRule;
/**
* A predicate that determines whether or not a certain combination of
* prefixes is allowed. If it returns false, a combination of prefixes will
* not be allowed. Prefixes are put in the list from right to left.
*/
private Predicate> prefixRepetitionRule;
/**
* The set of units that is considered neither metric nor nonmetric for the
* purposes of the metric-imperial one-way conversion. These units are
* included in both From and To, even if One Way Conversion is enabled.
*/
private final Set metricExceptions;
/**
* If this is true, views that show units as a list will have metric units
* removed from the From unit list and imperial/USC units removed from the To
* unit list.
*/
private boolean oneWayConversionEnabled;
/**
* If this is false, duplicate units will be removed from the unit view in
* views that show units as a list to choose from.
*/
private boolean showDuplicateUnits;
/**
* Creates a Presenter
*
* @param view the view that this presenter communicates with
* @since 2021-12-15
*/
public Presenter(View view) {
this.view = view;
this.database = new UnitDatabase();
addDefaults(this.database);
// load units and prefixes
try (final InputStream units = inputStream(DEFAULT_UNITS_FILEPATH)) {
this.database.loadUnitsFromStream(units);
} catch (final IOException e) {
throw new AssertionError("Loading of unitsfile.txt failed.", e);
}
// load dimensions
try (final InputStream dimensions = inputStream(
DEFAULT_DIMENSIONS_FILEPATH)) {
this.database.loadDimensionsFromStream(dimensions);
} catch (final IOException e) {
throw new AssertionError("Loading of dimensionfile.txt failed.", e);
}
// load metric exceptions
try {
this.metricExceptions = new HashSet<>();
try (InputStream exceptions = inputStream(DEFAULT_EXCEPTIONS_FILEPATH);
Scanner scanner = new Scanner(exceptions)) {
while (scanner.hasNextLine()) {
final String line = Presenter
.withoutComments(scanner.nextLine());
if (!line.isBlank()) {
this.metricExceptions.add(line);
}
}
}
} catch (final IOException e) {
throw new AssertionError("Loading of metric_exceptions.txt failed.",
e);
}
}
/**
* Converts from the view's input expression to its output expression.
* Displays an error message if any of the required fields are invalid.
*
* @throws UnsupportedOperationException if the view does not support
* expression-based conversion (does
* not implement
* {@link ExpressionConversionView})
* @since 2021-12-15
*/
public void convertExpressions() {
if (this.view instanceof ExpressionConversionView) {
final ExpressionConversionView xcview = (ExpressionConversionView) this.view;
final String fromExpression = xcview.getFromExpression();
final String toExpression = xcview.getToExpression();
// expressions must not be empty
if (fromExpression.isEmpty()) {
this.view.showErrorMessage("Parse Error",
"Please enter a unit expression in the From: box.");
return;
}
if (toExpression.isEmpty()) {
this.view.showErrorMessage("Parse Error",
"Please enter a unit expression in the To: box.");
return;
}
// evaluate expressions
final LinearUnitValue from;
final Unit to;
try {
from = this.database.evaluateUnitExpression(fromExpression);
} catch (final IllegalArgumentException | NoSuchElementException e) {
this.view.showErrorMessage("Parse Error",
"Could not recognize text in From entry: " + e.getMessage());
return;
}
try {
to = this.database.getUnitFromExpression(toExpression);
} catch (final IllegalArgumentException | NoSuchElementException e) {
this.view.showErrorMessage("Parse Error",
"Could not recognize text in To entry: " + e.getMessage());
return;
}
// convert and show output
if (from.getUnit().canConvertTo(to)) {
final double value = from.asUnitValue().convertTo(to).getValue();
final UnitConversionRecord uc = UnitConversionRecord.valueOf(
fromExpression, toExpression, "", String.valueOf(value));
xcview.showExpressionConversionOutput(uc);
} else {
this.view.showErrorMessage("Conversion Error",
"Cannot convert between \"" + fromExpression + "\" and \""
+ toExpression + "\".");
}
} else
throw new UnsupportedOperationException(
"This function can only be called when the view is an ExpressionConversionView");
}
/**
* Converts from the view's input unit to its output unit. Displays an error
* message if any of the required fields are invalid.
*
* @throws UnsupportedOperationException if the view does not support
* unit-based conversion (does not
* implement
* {@link UnitConversionView})
* @since 2021-12-15
*/
public void convertUnits() {
if (this.view instanceof UnitConversionView) {
final UnitConversionView ucview = (UnitConversionView) this.view;
final Optional fromUnitOptional = ucview.getFromSelection();
final Optional toUnitOptional = ucview.getToSelection();
final String valueString = ucview.getInputValue();
// extract values from optionals
final String fromUnitString, toUnitString;
if (fromUnitOptional.isPresent()) {
fromUnitString = fromUnitOptional.orElseThrow();
} else {
this.view.showErrorMessage("Unit Selection Error",
"Please specify a From unit");
return;
}
if (toUnitOptional.isPresent()) {
toUnitString = toUnitOptional.orElseThrow();
} else {
this.view.showErrorMessage("Unit Selection Error",
"Please specify a To unit");
return;
}
// convert strings to data, checking if anything is invalid
final Unit fromUnit, toUnit;
final double value;
if (this.database.containsUnitName(fromUnitString)) {
fromUnit = this.database.getUnit(fromUnitString);
} else
throw this.viewError("Nonexistent From unit: %s", fromUnitString);
if (this.database.containsUnitName(toUnitString)) {
toUnit = this.database.getUnit(toUnitString);
} else
throw this.viewError("Nonexistent To unit: %s", toUnitString);
try {
value = Double.parseDouble(valueString);
} catch (final NumberFormatException e) {
this.view.showErrorMessage("Value Error",
"Invalid value " + valueString);
return;
}
if (!fromUnit.canConvertTo(toUnit))
throw this.viewError("Could not convert between %s and %s",
fromUnit, toUnit);
// convert!
final UnitValue initialValue = UnitValue.of(fromUnit, value);
final UnitValue converted = initialValue.convertTo(toUnit);
ucview.showUnitConversionOutput(
UnitConversionRecord.fromValues(initialValue, converted));
} else
throw new UnsupportedOperationException(
"This function can only be called when the view is a UnitConversionView.");
}
/**
* @return true iff duplicate units are shown in unit lists
* @since 2022-03-30
*/
public boolean duplicateUnitsShown() {
return this.showDuplicateUnits;
}
/**
* Gets a name for this dimension using the database
*
* @param dimension dimension to name
* @return name of dimension
* @since 2022-04-16
*/
final String getDimensionName(ObjectProduct dimension) {
// find this dimension in the database and get its name
// if it isn't there, use the dimension's toString instead
return this.database.dimensionMap().values().stream()
.filter(d -> d.equals(dimension)).findAny().map(Nameable::getName)
.orElse(dimension.toString(Nameable::getName));
}
/**
* @return the rule that is used by this presenter to convert numbers into
* strings
* @since 2022-04-10
*/
public Function getNumberDisplayRule() {
return this.numberDisplayRule;
}
/**
* @return the rule that is used by this presenter to convert strings into
* numbers
* @since 2022-04-10
*/
@SuppressWarnings("unused") // not implemented yet
private Function getNumberParsingRule() {
return this.numberParsingRule;
}
/**
* @return type of unit {@code u}
* @since 2022-04-16
*/
private final UnitType getUnitType(Unit u) {
// determine if u is an exception
final var primaryName = u.getPrimaryName();
final var symbol = u.getSymbol();
final boolean isException = primaryName.isPresent()
&& this.metricExceptions.contains(primaryName.orElseThrow())
|| symbol.isPresent()
&& this.metricExceptions.contains(symbol.orElseThrow());
// determine unit type
if (isException)
return UnitType.SEMI_METRIC;
else if (u.isMetric())
return UnitType.METRIC;
else
return UnitType.NON_METRIC;
}
/**
* Loads settings from the user's settings file and applies them to the
* presenter.
*
* @param settingsFile file settings should be loaded from
* @since 2021-12-15
*/
void loadSettings(Path settingsFile) {}
/**
* @return true iff the One-Way Conversion feature is available (views that
* show units as a list will have metric units removed from the From
* unit list and imperial/USC units removed from the To unit list)
*
* @since 2022-03-30
*/
public boolean oneWayConversionEnabled() {
return this.oneWayConversionEnabled;
}
/**
* Completes creation of the presenter. This part of the initialization
* depends on the view's functions, so it cannot be run if the components
* they depend on are not created yet.
*
* @since 2022-02-26
*/
public void postViewInitialize() {
// unit conversion specific stuff
if (this.view instanceof UnitConversionView) {
final UnitConversionView ucview = (UnitConversionView) this.view;
ucview.setDimensionNames(this.database.dimensionMap().keySet());
}
// load units & prefixes into viewers
this.view.setViewableUnitNames(
this.database.unitMapPrefixless(this.showDuplicateUnits).keySet());
this.view.setViewablePrefixNames(this.database.prefixMap().keySet());
}
void prefixSelected() {
final Optional selectedPrefixName = this.view
.getViewedPrefixName();
final Optional selectedPrefix = selectedPrefixName
.map(name -> this.database.containsPrefixName(name)
? this.database.getPrefix(name)
: null);
selectedPrefix
.ifPresent(prefix -> this.view.showPrefix(prefix.getNameSymbol(),
String.valueOf(prefix.getMultiplier())));
}
/**
* Saves the presenter's settings to the user settings file.
*
* @param settingsFile file settings should be saved to
* @since 2021-12-15
*/
void saveSettings(Path settingsFile) {}
/**
* @param numberDisplayRule the new rule that will be used by this presenter
* to convert numbers into strings
* @since 2022-04-10
*/
public void setNumberDisplayRule(
Function numberDisplayRule) {
this.numberDisplayRule = numberDisplayRule;
}
/**
* @param numberParsingRule the new rule that will be used by this presenter
* to convert strings into numbers
* @since 2022-04-10
*/
@SuppressWarnings("unused") // not implemented yet
private void setNumberParsingRule(
Function numberParsingRule) {
this.numberParsingRule = numberParsingRule;
}
/**
* @param oneWayConversionEnabled whether not one-way conversion should be
* enabled
* @since 2022-03-30
* @see {@link #isOneWayConversionEnabled}
*/
public void setOneWayConversionEnabled(boolean oneWayConversionEnabled) {
this.oneWayConversionEnabled = oneWayConversionEnabled;
this.updateView();
}
/**
* @param showDuplicateUnits whether or not duplicate units should be shown
* @since 2022-03-30
*/
public void setShowDuplicateUnits(boolean showDuplicateUnits) {
this.showDuplicateUnits = showDuplicateUnits;
this.updateView();
}
/**
* Shows a unit in the unit viewer
*
* @param u unit to show
* @since 2022-04-16
*/
private final void showUnit(Unit u) {
final var nameSymbol = u.getNameSymbol();
final boolean isBase = u instanceof BaseUnit
|| u instanceof LinearUnit && ((LinearUnit) u).isBase();
final var definition = isBase ? "(Base unit)" : u.toDefinitionString();
final var dimensionString = this.getDimensionName(u.getDimension());
final var unitType = this.getUnitType(u);
this.view.showUnit(nameSymbol, definition, dimensionString, unitType);
}
/**
* Runs whenever a unit name is selected in the unit viewer. Gets the
* description of a unit and displays it.
*
* @since 2022-04-10
*/
void unitNameSelected() {
// get selected unit, if it's there and valid
final Optional selectedUnitName = this.view.getViewedUnitName();
final Optional selectedUnit = selectedUnitName
.map(unitName -> this.database.containsUnitName(unitName)
? this.database.getUnit(unitName)
: null);
selectedUnit.ifPresent(this::showUnit);
}
/**
* Updates the view's From and To units, if it has some
*
* @since 2021-12-15
*/
public void updateView() {
if (this.view instanceof UnitConversionView) {
final UnitConversionView ucview = (UnitConversionView) this.view;
final ObjectProduct viewDimension = this.database
.getDimension(((UnitConversionView) this.view)
.getSelectedDimensionName().orElseThrow());
final Set units = this.database
.unitMapPrefixless(this.showDuplicateUnits).entrySet().stream()
.map(Map.Entry::getValue)
.filter(u -> viewDimension.equals(u.getDimension()))
.map(Unit::getName).collect(Collectors.toSet());
ucview.setFromUnitNames(units);
ucview.setToUnitNames(units);
}
}
/**
* @param message message to add
* @param args string formatting arguments for message
* @return AssertionError stating that an error has happened in the view's
* code
* @since 2022-04-09
*/
private AssertionError viewError(String message, Object... args) {
return new AssertionError("View Programming Error (from " + this.view
+ "): " + String.format(message, args));
}
}