MeasureFormatter.java

/*
 * Copyright (C) 2018 Alberto Irurueta Carro (alberto@irurueta.com)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.irurueta.units;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.FieldPosition;
import java.text.MessageFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
import java.util.Objects;

/**
 * Base class to format and parse a given measure using its value and unit.
 *
 * @param <M> type of measurement (i.e. Distance or Surface).
 * @param <U> type of unit (i.e. DistanceUnit or SurfaceUnit).
 */
@SuppressWarnings("WeakerAccess")
public abstract class MeasureFormatter<M extends Measurement<U>, U extends Enum<?>> implements Cloneable {

    /**
     * Default pattern to format values and units together into a single string.
     * {0} corresponds to the value, {1} corresponds to the unit part.
     */
    public static final String DEFAULT_VALUE_AND_UNIT_FORMAT_PATTERN = "{0} {1}";

    /**
     * Internal string formatter.
     */
    NumberFormat numberFormat;

    /**
     * Internal string formatter.
     */
    private MessageFormat format;

    /**
     * Internal locale.
     */
    private Locale locale;

    /**
     * Pattern to format values and unit together into a single string. {0} corresponds to
     * the value, {1} corresponds to the unit part.
     */
    private String valueAndUnitFormatPattern;

    /**
     * Constructor.
     */
    MeasureFormatter() {
        numberFormat = NumberFormat.getInstance();
        locale = Locale.getDefault();
        valueAndUnitFormatPattern = DEFAULT_VALUE_AND_UNIT_FORMAT_PATTERN;
    }

    /**
     * Constructor with locale.
     *
     * @param locale locale.
     * @throws IllegalArgumentException if locale is null.
     */
    MeasureFormatter(final Locale locale) {
        if (locale == null) {
            throw new IllegalArgumentException();
        }

        numberFormat = NumberFormat.getInstance(locale);
        this.locale = locale;
        valueAndUnitFormatPattern = DEFAULT_VALUE_AND_UNIT_FORMAT_PATTERN;
    }

    /**
     * Determines if two measure formatters are equal by comparing all of its internal
     * parameters.
     *
     * @param obj another object to compare.
     * @return true if provided object is assumed to be equal to this instance.
     */
    @Override
    public boolean equals(final Object obj) {
        if (obj == null) {
            return false;
        }
        if (!(obj instanceof MeasureFormatter)) {
            return false;
        }
        if (this == obj) {
            return true;
        }

        //noinspection unchecked
        final var other = (MeasureFormatter<M, U>) obj;
        return numberFormat.equals(other.numberFormat) &&
                locale.equals(other.locale) &&
                valueAndUnitFormatPattern.equals(other.valueAndUnitFormatPattern);
    }

    /**
     * Hash code generated for this instance.
     * Hash codes can be internally used by some collections to coarsely compare objects.
     *
     * @return hash code.
     */
    @Override
    public int hashCode() {
        return Objects.hash(numberFormat, format, locale, valueAndUnitFormatPattern);
    }

    /**
     * Formats provided measurement value and unit into a string representation.
     *
     * @param value a measurement value.
     * @param unit  a measurement unit.
     * @return string representation of provided measurement value and unit.
     */
    public String format(final Number value, final U unit) {
        return MessageFormat.format(valueAndUnitFormatPattern, numberFormat.format(value), getUnitSymbol(unit));
    }

    /**
     * Formats provided measurement value and unit into a string representation
     * and appends the result into provided string buffer.
     *
     * @param value      a measurement value.
     * @param unit       a measurement unit.
     * @param toAppendTo buffer to append the result to.
     * @param pos        field position where result will be appended.
     * @return provided string buffer where result is appended.
     */
    public StringBuffer format(
            final Number value, final U unit,
            final StringBuffer toAppendTo, final FieldPosition pos) {
        if (format == null) {
            format = new MessageFormat(valueAndUnitFormatPattern);
        }
        return format.format(new Object[]{numberFormat.format(value), getUnitSymbol(unit)}, toAppendTo, pos);
    }

    /**
     * Formats provided measurement value and unit into a string representation.
     *
     * @param value a measurement value.
     * @param unit  a measurement unit.
     * @return string representation of provided measurement value and unit.
     */
    public String format(final double value, final U unit) {
        return format(BigDecimal.valueOf(value), unit);
    }

    /**
     * Formats provided measurement value and unit into a string representation
     * and appends the result into provided string buffer.
     *
     * @param value      a measurement value.
     * @param unit       a measurement unit.
     * @param toAppendTo buffer to append the result to.
     * @param pos        field position where result will be appended.
     * @return provided string buffer where result is appended.
     */
    public StringBuffer format(
            final double value, final U unit, final StringBuffer toAppendTo, final FieldPosition pos) {
        return format(BigDecimal.valueOf(value), unit, toAppendTo, pos);
    }

    /**
     * Formats provided measurement into a string representation.
     *
     * @param measurement a measurement.
     * @return string representation of provided measurement.
     */
    public String format(final M measurement) {
        return format(measurement.getValue(), measurement.getUnit());
    }

    /**
     * Formats provided measurement into a string representation and appends the
     * result into provided string buffer.
     *
     * @param measurement a measurement.
     * @param toAppendTo  buffer to append the result to.
     * @param pos         field position where result will be appended.
     * @return provided string buffer where result is appended.
     */
    public StringBuffer format(
            final M measurement, final StringBuffer toAppendTo, final FieldPosition pos) {
        return format(measurement.getValue(), measurement.getUnit(), toAppendTo, pos);
    }

    /**
     * Formats and converts provided measurement value and unit using unit system
     * assigned to locale of this instance (if no locale has been provided it
     * is assumed that the system default locale is used).
     * If provided value is too large for provided unit, this method will
     * convert it to a more appropriate unit.
     *
     * @param value a measurement value.
     * @param unit  a measurement unit.
     * @return a string representation of measurement value and unit.
     */
    public String formatAndConvert(final Number value, final U unit) {
        return formatAndConvert(value, unit, getUnitSystem());
    }

    /**
     * Formats and converts provided measurement value and unit using unit system
     * assigned to locale of this instance (if no locale has been provided it
     * is assumed that the system default locale is used).
     * If provided value is too large for provided unit, this method will
     * convert it to a more appropriate unit.
     *
     * @param value a measurement value.
     * @param unit  a measurement unit.
     * @return a string representation of measurement value and unit.
     */
    public String formatAndConvert(final double value, final U unit) {
        return formatAndConvert(BigDecimal.valueOf(value), unit);
    }

    /**
     * Formats and converts provided measurement using unit system assigned to
     * locale of this instance (if no locale has been provided it is assumed
     * that the system default locale is used).
     * If provided measurement value is too large for its unit, this method
     * will convert it to a more appropriate unit.
     *
     * @param measurement measurement to be formatted.
     * @return a string representation of measurement value and unit.
     */
    public String formatAndConvert(final M measurement) {
        return formatAndConvert(measurement.getValue(), measurement.getUnit());
    }

    /**
     * Formats and converts provided measurement value and unit using provided
     * unit system.
     * If provided value is too large for provided unit, this method will
     * convert it to a more appropriate unit using provided unit system (either
     * metric or imperial).
     *
     * @param value  a measurement value.
     * @param unit   a measurement unit.
     * @param system system unit to convert measurement to.
     * @return a string representation of measurement value and unit.
     */
    public abstract String formatAndConvert(final Number value, final U unit, final UnitSystem system);

    /**
     * Formats and converts provided measurement value and unit using provided
     * unit system.
     * If provided value is too large for provided unit, this method will
     * convert it to a more appropriate unit using provided unit system (either
     * metric or imperial).
     *
     * @param value  a measurement value.
     * @param unit   a measurement unit.
     * @param system system unit to convert measurement to.
     * @return a string representation of measurement value and unit.
     */
    public String formatAndConvert(final double value, final U unit, final UnitSystem system) {
        return formatAndConvert(BigDecimal.valueOf(value), unit, system);
    }

    /**
     * Formats and converts provided measurement using provided unit system.
     * If provided measurement value is too large for its unit, this method
     * will convert it to a more appropriate unit using provided unit
     * system.
     *
     * @param measurement a measurement to be formatted.
     * @param unitSystem  system unit to convert measurement to.
     * @return a string representation of measurement value and unit.
     */
    public String formatAndConvert(final M measurement, final UnitSystem unitSystem) {
        return formatAndConvert(measurement.getValue(), measurement.getUnit(), unitSystem);
    }

    /**
     * Returns available locales for this formatter.
     *
     * @return available locales.
     */
    public static Locale[] getAvailableLocales() {
        return NumberFormat.getAvailableLocales();
    }

    /**
     * Gets locale assigned to this instance.
     * Locale determines number format and unit system (metric or imperial)
     * if not specified.
     *
     * @return a locale.
     */
    public Locale getLocale() {
        return locale;
    }

    /**
     * Returns maximum fraction digits to be shown when formatting a measure.
     *
     * @return maximum fraction digits.
     */
    public int getMaximumFractionDigits() {
        return numberFormat.getMaximumFractionDigits();
    }

    /**
     * Sets maximum fraction digits to use when formatting a measure.
     *
     * @param newValue maximum fraction digits to be set.
     */
    public void setMaximumFractionDigits(final int newValue) {
        numberFormat.setMaximumFractionDigits(newValue);
    }

    /**
     * Returns maximum integer digits to be shown when formatting a measure.
     *
     * @return maximum integer digits.
     */
    public int getMaximumIntegerDigits() {
        return numberFormat.getMaximumIntegerDigits();
    }

    /**
     * Sets maximum integer digits to use when formatting a measure.
     *
     * @param newValue maximum integer digits to be set.
     */
    public void setMaximumIntegerDigits(final int newValue) {
        numberFormat.setMaximumIntegerDigits(newValue);
    }

    /**
     * Returns minimum fraction digits to be shown when formatting a measure.
     *
     * @return minimum fraction digits.
     */
    public int getMinimumFractionDigits() {
        return numberFormat.getMinimumFractionDigits();
    }

    /**
     * Sets minimum fraction digits to use when formatting a measure.
     *
     * @param newValue minimum fraction digits to be set.
     */
    public void setMinimumFractionDigits(final int newValue) {
        numberFormat.setMinimumFractionDigits(newValue);
    }

    /**
     * Returns minimum integer digits to be shown when formatting a measure.
     *
     * @return minimum integer digits.
     */
    public int getMinimumIntegerDigits() {
        return numberFormat.getMinimumIntegerDigits();
    }

    /**
     * Sets minimum integer digits to use when formatting a measure.
     *
     * @param newValue minimum integer digits to be set.
     */
    public void setMinimumIntegerDigits(final int newValue) {
        numberFormat.setMinimumIntegerDigits(newValue);
    }

    /**
     * Returns rounding mode to be used when formatting a measure.
     *
     * @return rounding mode to be used when formatting a measure.
     */
    public RoundingMode getRoundingMode() {
        return numberFormat.getRoundingMode();
    }

    /**
     * Specifies rounding mode to use when formatting a measure.
     *
     * @param roundingMode rounding mode to be set.
     */
    public void setRoundingMode(final RoundingMode roundingMode) {
        numberFormat.setRoundingMode(roundingMode);
    }

    /**
     * Indicates if grouping is used when formatting a measure.
     *
     * @return true if grouping is used, false otherwise.
     */
    public boolean isGroupingUsed() {
        return numberFormat.isGroupingUsed();
    }

    /**
     * Sets if grouping is used when formatting a measure.
     *
     * @param newValue true if grouping is enabled, false otherwise.
     */
    public void setGroupingUsed(final boolean newValue) {
        numberFormat.setGroupingUsed(newValue);
    }

    /**
     * Indicates if only integer values are parsed.
     *
     * @return true if only integer values are parsed, false otherwise.
     */
    public boolean isParseIntegerOnly() {
        return numberFormat.isParseIntegerOnly();
    }

    /**
     * Specifies whether only integer values are parsed or not.
     *
     * @param value if true only integer values will be parsed.
     */
    public void setParseIntegerOnly(final boolean value) {
        numberFormat.setParseIntegerOnly(value);
    }

    /**
     * Obtains pattern to format values and unit together into a single string.
     * {0} corresponds to the value, {1} corresponds to the unit part.
     *
     * @return pattern to format values and unit together.
     */
    public String getValueAndUnitFormatPattern() {
        return valueAndUnitFormatPattern;
    }

    /**
     * Sets pattern to format values and unit together into a single string.
     * {0} corresponds to the value, {1} corresponds to the unit part.
     *
     * @param valueAndUnitFormatPattern pattern to format values and unit
     *                                  together.
     * @throws IllegalArgumentException if provided pattern is null.
     */
    public void setValueAndUnitFormatPattern(final String valueAndUnitFormatPattern) {
        if (valueAndUnitFormatPattern == null) {
            throw new IllegalArgumentException();
        }
        this.valueAndUnitFormatPattern = valueAndUnitFormatPattern;
    }

    /**
     * Returns unit system this instance will use based on its assigned locale.
     * Notice that if no locale was assigned, then the default system locale
     * will be used.
     *
     * @return unit system this instance will use.
     */
    public UnitSystem getUnitSystem() {
        return UnitLocale.getFrom(locale);
    }

    /**
     * Indicates whether provided string representation contains a valid
     * measurement unit or not.
     *
     * @param source a string measurement representation to be checked.
     * @return true if provided string has a valid (i.e. recognized) unit, false
     * otherwise.
     */
    public boolean isValidUnit(final String source) {
        return findUnit(source) != null;
    }

    /**
     * Indicates whether provided string representation is a valid measurement
     * representation or not.
     *
     * @param source a string measurement representation to be checked.
     * @return true if provided string representation is valid (contains valid
     * value and unit), false otherwise.
     */
    public boolean isValidMeasurement(final String source) {
        try {
            numberFormat.parse(source);
            return isValidUnit(source);
        } catch (final ParseException e) {
            return false;
        }
    }

    /**
     * Indicates whether provided string representation of a measurement
     * contains a metric system unit.
     *
     * @param source a measurement string representation to be checked.
     * @return true if found unit is metric, false otherwise or if unit
     * cannot be determined.
     */
    public boolean isMetricUnit(final String source) {
        return getUnitSystem(source) == UnitSystem.METRIC;
    }

    /**
     * Indicates whether provided string representation of a measurement
     * contains an imperial system unit.
     *
     * @param source a measurement string representation to be checked.
     * @return true if found unit is imperial, false otherwise or if unit
     * cannot be determined.
     */
    public boolean isImperialUnit(final String source) {
        return getUnitSystem(source) == UnitSystem.IMPERIAL;
    }

    /**
     * Gets unit system for detected unit into provided string representation
     * of a measurement.
     *
     * @param source a measurement string representation to be checked.
     * @return a unit system (either metric or imperial) or null if unit
     * cannot be determined.
     */
    public abstract UnitSystem getUnitSystem(final String source);

    /**
     * Parses a string into a measure.
     *
     * @param source text to be parsed.
     * @return a measure containing measure value and unit obtained from parsed
     * text.
     * @throws ParseException       if parsing failed.
     * @throws UnknownUnitException if unit cannot be determined.
     */
    public abstract M parse(final String source) throws ParseException, UnknownUnitException;

    /**
     * Finds measure unit from within a measurement string representation.
     *
     * @param source a measurement string representation.
     * @return a measure unit or null if none can be determined.
     */
    public abstract U findUnit(final String source);

    /**
     * Obtains measure unit symbol.
     *
     * @param unit a measure unit.
     * @return measure unit symbol.
     */
    public abstract String getUnitSymbol(final U unit);

    /**
     * Internal method to parse a string into a measure.
     *
     * @param source  text to be parsed.
     * @param measure a measure to be initialized with parsed contents.
     * @return provided measure.
     * @throws ParseException       if parsing failed.
     * @throws UnknownUnitException if unit cannot be determined.
     */
    M internalParse(final String source, final M measure) throws ParseException, UnknownUnitException {
        measure.setValue(numberFormat.parse(source));
        try {
            measure.setUnit(findUnit(source));
        } catch (final IllegalArgumentException e) {
            throw new UnknownUnitException(e);
        }
        return measure;
    }

    /**
     * Internal method to clone this measure formatter.
     *
     * @param copy an instantiated copy of a measure formatter that needs to be initialized.
     * @return provided copy.
     */
    MeasureFormatter<M, U> internalClone(final MeasureFormatter<M, U> copy) {
        copy.numberFormat = (NumberFormat) numberFormat.clone();
        if (format != null) {
            copy.format = (MessageFormat) format.clone();
        }
        if (locale != null) {
            copy.locale = (Locale) locale.clone();
        }
        return copy;
    }

    @Override
    protected MeasureFormatter<M , U> clone() throws CloneNotSupportedException {
        //noinspection unchecked
        return internalClone((MeasureFormatter<M , U>) super.clone());
    }
}