diff --git a/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldApp.java b/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldApp.java index 10e962d..7b81042 100644 --- a/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldApp.java +++ b/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldApp.java @@ -1,7 +1,6 @@ package com.dlsc.phonenumberfx.demo; import com.dlsc.phonenumberfx.PhoneNumberField; -import com.google.i18n.phonenumbers.Phonenumber; import javafx.application.Application; import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; @@ -29,19 +28,11 @@ public class PhoneNumberFieldApp extends Application { return null; } PhoneNumberField.CountryCallingCode code = (PhoneNumberField.CountryCallingCode) c; - return "(+" + code.phonePrefix() + ") " + code; - }; - - private static final Function PHONE_NUMBER_CONVERTER = c -> { - if (c == null) { - return null; - } - Phonenumber.PhoneNumber number = (Phonenumber.PhoneNumber) c; - return number.getRawInput(); + return "(" + code.phonePrefix() + ") " + code; }; @Override - public void start(Stage stage) throws Exception { + public void start(Stage stage) { PhoneNumberField field = new PhoneNumberField(); VBox controls = new VBox(10); @@ -52,7 +43,9 @@ public void start(Stage stage) throws Exception { VBox fields = new VBox(10); addField(fields, "Country Code", field.countryCallingCodeProperty(), COUNTRY_CODE_CONVERTER); - addField(fields, "Phone Number", field.phoneNumberProperty(), PHONE_NUMBER_CONVERTER); + addField(fields, "Raw PhoneNumber", field.rawPhoneNumberProperty()); + addField(fields, "E164 PhoneNumber", field.e164PhoneNumberProperty()); + addField(fields, "National PhoneNumber", field.nationalPhoneNumberProperty()); VBox vBox = new VBox(20); vBox.setPadding(new Insets(20)); @@ -109,7 +102,7 @@ private Node disableCountryCheck(PhoneNumberField field) { private Node clearButton(PhoneNumberField field) { Button clear = new Button("Clear all"); - clear.setOnAction(evt -> field.setPhoneNumber(null)); + clear.setOnAction(evt -> field.setRawPhoneNumber(null)); return clear; } diff --git a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java index ff354b6..982abd8 100644 --- a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java +++ b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java @@ -2,15 +2,18 @@ import com.dlsc.phonenumberfx.skins.PhoneNumberFieldSkin; import com.google.i18n.phonenumbers.AsYouTypeFormatter; -import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.ReadOnlyStringWrapper; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; @@ -32,8 +35,8 @@ import java.util.function.UnaryOperator; /** - *

A control for entering phone numbers. By default, the phone numbers are expressed in international format and will be - * delivered via the {@link #phoneNumberProperty() phone number} property.

+ *

A control for entering phone numbers. By default, the phone numbers are expressed in international format, including + * the country calling code and delivered by the {@link #rawPhoneNumberProperty() phone number} property.

* *

The control supports a list of {@link #getAvailableCountryCodes() available country codes} and works based on * the {@link CountryCallingCode CountryCallingCode} interface. This interface allows customizing the country codes and their @@ -42,6 +45,9 @@ */ public class PhoneNumberField extends Control { + /** + * Pseudo class used to visualize the error state of the control. + */ public static final PseudoClass ERROR_PSEUDO_CLASS = PseudoClass.getPseudoClass("error"); /** @@ -67,20 +73,8 @@ public PhoneNumberField() { resolver = new CountryCallingCodeResolver(); formatter = new PhoneNumberFormatter(textField); - Runnable formatUpdater = () -> Platform.runLater(() -> formatter.setFormattedLocalPhoneNumber(getPhoneNumber())); - Runnable validUpdater = () -> Platform.runLater(() -> { - Phonenumber.PhoneNumber number = getPhoneNumber(); - if (number == null) { - setValid(true); - } else { - setValid(phoneNumberUtil.isValidNumber(number)); - } - }); - - phoneNumber.addListener((obs, oldV, newV) -> { - formatUpdater.run(); - validUpdater.run(); - }); + rawPhoneNumberProperty().addListener((obs, oldV, newV) -> Platform.runLater(() -> formatter.setFormattedNationalNumber(getRawPhoneNumber()))); + validProperty().addListener((obs, oldV, newV) -> pseudoClassStateChanged(ERROR_PSEUDO_CLASS, !newV)); } @Override @@ -94,10 +88,10 @@ protected Skin createDefaultSkin() { } // VALUES - private final ObjectProperty phoneNumber = new SimpleObjectProperty<>(this, "phoneNumber") { + private final StringProperty rawPhoneNumber = new SimpleStringProperty(this, "rawPhoneNumber") { private boolean selfUpdate; @Override - public void set(Phonenumber.PhoneNumber newPhoneNumber) { + public void set(String newRawPhoneNumber) { if (selfUpdate) { return; } @@ -106,15 +100,33 @@ public void set(Phonenumber.PhoneNumber newPhoneNumber) { selfUpdate = true; // Set the value first, so that the binding will be triggered - super.set(newPhoneNumber); - - if (newPhoneNumber == null) { - setCountryCallingCode(null); - formatter.setFormattedLocalPhoneNumber(null); + super.set(newRawPhoneNumber); + + // Resolve all dependencies out of the raw phone number + CountryCallingCode code = resolver.call(newRawPhoneNumber); + + if (code != null) { + setCountryCallingCode(code); + formatter.setFormattedNationalNumber(newRawPhoneNumber); + + try { + Phonenumber.PhoneNumber number = phoneNumberUtil.parse(getRawPhoneNumber(), code.iso2Code()); + setValid(phoneNumberUtil.isValidNumber(number)); + setE164PhoneNumber(phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164)); + setNationalPhoneNumber(phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); + } catch (Exception e) { + setValid(true); + setE164PhoneNumber(null); + setNationalPhoneNumber(null); + } } else { - setCountryCallingCode(resolver.call(newPhoneNumber.getRawInput())); - formatter.setFormattedLocalPhoneNumber(newPhoneNumber); + setCountryCallingCode(null); + formatter.setFormattedNationalNumber(null); + setValid(true); + setE164PhoneNumber(null); + setNationalPhoneNumber(null); } + } finally { selfUpdate = false; } @@ -122,19 +134,19 @@ public void set(Phonenumber.PhoneNumber newPhoneNumber) { }; /** - * @return The phone number property acting as main value for the control. This is always represented international format - * without the (+) plus sign. + * @return The raw phone number corresponding exactly to what the user typed in, including the (+) sign appended at the + * beginning. This value can be a valid E164 formatted number. */ - public final ObjectProperty phoneNumberProperty() { - return phoneNumber; + public final StringProperty rawPhoneNumberProperty() { + return rawPhoneNumber; } - public final Phonenumber.PhoneNumber getPhoneNumber() { - return phoneNumberProperty().get(); + public final String getRawPhoneNumber() { + return rawPhoneNumberProperty().get(); } - public final void setPhoneNumber(Phonenumber.PhoneNumber phoneNumber) { - phoneNumberProperty().set(phoneNumber); + public final void setRawPhoneNumber(String rawPhoneNumber) { + rawPhoneNumberProperty().set(rawPhoneNumber); } private final ObjectProperty countryCallingCode = new SimpleObjectProperty<>(this, "countryCallingCode") { @@ -151,13 +163,8 @@ public void set(CountryCallingCode newCountryCallingCode) { // Set the value first, so that the binding will be triggered super.set(newCountryCallingCode); - if (newCountryCallingCode == null) { - setPhoneNumber(null); - } else { - setPhoneNumber(phoneNumberUtil.parseAndKeepRawInput(newCountryCallingCode.phonePrefix(), newCountryCallingCode.iso2Code())); - } - } catch (NumberParseException e) { - setPhoneNumber(null); + setRawPhoneNumber(newCountryCallingCode == null ? null : newCountryCallingCode.phonePrefix()); + } finally { selfUpdate = false; } @@ -180,6 +187,34 @@ private void setCountryCallingCode(CountryCallingCode countryCallingCode) { countryCallingCodeProperty().set(countryCallingCode); } + private final ReadOnlyStringWrapper nationalPhoneNumber = new ReadOnlyStringWrapper(this, "nationalPhoneNumber"); + + public final ReadOnlyStringProperty nationalPhoneNumberProperty() { + return nationalPhoneNumber.getReadOnlyProperty(); + } + + public final String getNationalPhoneNumber() { + return nationalPhoneNumber.get(); + } + + private void setNationalPhoneNumber(String nationalPhoneNumber) { + this.nationalPhoneNumber.set(nationalPhoneNumber); + } + + private final ReadOnlyStringWrapper e164PhoneNumber = new ReadOnlyStringWrapper(this, "e164PhoneNumber"); + + public final ReadOnlyStringProperty e164PhoneNumberProperty() { + return e164PhoneNumber.getReadOnlyProperty(); + } + + public final String getE164PhoneNumber() { + return e164PhoneNumber.get(); + } + + private void setE164PhoneNumber(String e164PhoneNumber) { + this.e164PhoneNumber.set(e164PhoneNumber); + } + // SETTINGS private final ObservableList availableCountryCodes = FXCollections.observableArrayList(); @@ -205,8 +240,8 @@ public final ObservableList getPreferredCountryCodes() { private final BooleanProperty disableCountryCode = new SimpleBooleanProperty(this, "disableCountryCode"); /** - * @return Flag to disable the country selector button. This is useful if you want to force the user to enter a local number but - * make split reference that the {@link #phoneNumberProperty() phoneNumber} is still international. + * @return Flag to disable the country selector button. This will allow to specify a default country code and avoid changing it + * in case it is wanted to be fixed. */ public final BooleanProperty disableCountryCodeProperty() { return disableCountryCode; @@ -289,11 +324,18 @@ default Integer defaultAreaCode() { return areaCodes().length > 0 ? areaCodes()[0] : null; } + /** + * @return The concatenation of country code and {@link #defaultAreaCode() default area code} without the plus sign. + */ + default String countryCodePrefix() { + return "+" + countryCode(); + } + /** * @return The concatenation of country code and {@link #defaultAreaCode() default area code} without the plus sign. */ default String phonePrefix() { - return countryCode() + Optional.ofNullable(defaultAreaCode()).map(Object::toString).orElse(""); + return countryCodePrefix() + Optional.ofNullable(defaultAreaCode()).map(Object::toString).orElse(""); } /** @@ -588,7 +630,7 @@ private PhoneNumberFormatter(TextField textField) { && getCountryCallingCode() != null) { // Clear up the country code if the user deletes the entire text - setPhoneNumber(null); + setRawPhoneNumber(null); e.consume(); } }); @@ -604,33 +646,36 @@ public TextFormatter.Change apply(TextFormatter.Change change) { try { selfUpdate = true; + CountryCallingCode code = getCountryCallingCode(); - if (change.isAdded() || change.isReplaced()) { + if (change.isAdded()) { String text = change.getText(); - if (getCountryCallingCode() == null && text.equals("+") && change.getControlNewText().equals("+")) { - // Only allow if it is the first character - return change; + + if (code == null && text.startsWith("+")) { + text = text.substring(1); } - if (!text.matches("[0-9]+")) { + if (!text.isEmpty() && !text.matches("[0-9]+")) { return null; } + + if (code == null && !change.getControlNewText().startsWith("+")) { + change.setText("+" + change.getText()); + change.setCaretPosition(change.getCaretPosition() + 1); + change.setAnchor(change.getAnchor() + 1); + } } if (change.isContentChange()) { - if (getCountryCallingCode() == null) { + if (code == null) { resolveCountryCode(change); } else { - CountryCallingCode code = getCountryCallingCode(); - String newNationalNumber = undoFormat(change.getControlNewText()); - String newPhoneNumber = code.countryCode() + newNationalNumber; - - setPhoneNumber(phoneNumberUtil.parseAndKeepRawInput(newPhoneNumber, code.iso2Code())); + String nationalNumber = undoFormat(change.getControlNewText()); + String newPhoneNumber = code.countryCodePrefix() + nationalNumber; + setRawPhoneNumber(newPhoneNumber); } } - } catch (NumberParseException e) { - setPhoneNumber(null); } finally { selfUpdate = false; } @@ -639,14 +684,10 @@ public TextFormatter.Change apply(TextFormatter.Change change) { } private void resolveCountryCode(TextFormatter.Change change) { - String newText = change.getControlNewText(); - - CountryCallingCode code = resolver.call(newText); + CountryCallingCode code = resolver.call(change.getControlNewText()); if (code != null) { setCountryCallingCode(code); - if (code.defaultAreaCode() != null) { - textField.setText(String.valueOf(code.defaultAreaCode())); - } + textField.setText(Optional.ofNullable(code.defaultAreaCode()).map(String::valueOf).orElse("")); change.setText(""); change.setCaretPosition(0); change.setAnchor(0); @@ -654,21 +695,20 @@ private void resolveCountryCode(TextFormatter.Change change) { } } - private String doFormat(Phonenumber.PhoneNumber phoneNumber) { - if (phoneNumber == null) { + private String doFormat(String newRawPhoneNumber) { + if (newRawPhoneNumber == null || newRawPhoneNumber.isEmpty() || getCountryCallingCode() == null) { return ""; } - String prefix = String.valueOf(phoneNumber.getCountryCode()); - String rawInput = phoneNumber.getRawInput(); - AsYouTypeFormatter formatter = phoneNumberUtil.getAsYouTypeFormatter(getCountryCallingCode().iso2Code()); + CountryCallingCode code = getCountryCallingCode(); + AsYouTypeFormatter formatter = phoneNumberUtil.getAsYouTypeFormatter(code.iso2Code()); String formattedNumber = ""; - for (char c : rawInput.toCharArray()) { + for (char c : newRawPhoneNumber.toCharArray()) { formattedNumber = formatter.inputDigit(c); } - return formattedNumber.substring(prefix.length()); + return formattedNumber.substring(code.countryCodePrefix().length()).trim(); } private String undoFormat(String formattedLocalPhoneNumber) { @@ -685,14 +725,14 @@ private String undoFormat(String formattedLocalPhoneNumber) { return phoneNumber.toString(); } - private void setFormattedLocalPhoneNumber(Phonenumber.PhoneNumber phoneNumber) { + private void setFormattedNationalNumber(String newRawPhoneNumber) { if (selfUpdate) { return; // Ignore when I'm the one who initiated the update } try { selfUpdate = true; - String formattedPhoneNumber = doFormat(phoneNumber); + String formattedPhoneNumber = doFormat(newRawPhoneNumber); textField.setText(formattedPhoneNumber); textField.positionCaret(formattedPhoneNumber.length()); } finally { @@ -709,6 +749,17 @@ private final class CountryCallingCodeResolver implements Callback> scores = new TreeMap<>(); for (CountryCallingCode code : getAvailableCountryCodes()) { @@ -727,23 +778,17 @@ public CountryCallingCode call(String phoneNumber) { } private int calculateScore(CountryCallingCode code, String phoneNumber) { - if (phoneNumber != null) { - if (phoneNumber.startsWith("+")) { - phoneNumber = phoneNumber.substring(1); - } - - String countryPrefix = String.valueOf(code.countryCode()); + String countryPrefix = String.valueOf(code.countryCode()); - if (code.areaCodes().length == 0) { - if (phoneNumber.startsWith(countryPrefix)) { - return 1; - } - } else { - for (int areaCode : code.areaCodes()) { - String areaCodePrefix = countryPrefix + areaCode; - if (phoneNumber.startsWith(areaCodePrefix)) { - return 2; - } + if (code.areaCodes().length == 0) { + if (phoneNumber.startsWith(countryPrefix)) { + return 1; + } + } else { + for (int areaCode : code.areaCodes()) { + String areaCodePrefix = countryPrefix + areaCode; + if (phoneNumber.startsWith(areaCodePrefix)) { + return 2; } } } @@ -774,5 +819,3 @@ private CountryCallingCode inferBestMatch(List matchingCodes } } - - diff --git a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/skins/PhoneNumberFieldSkin.java b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/skins/PhoneNumberFieldSkin.java index 7e6f638..3494ae8 100644 --- a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/skins/PhoneNumberFieldSkin.java +++ b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/skins/PhoneNumberFieldSkin.java @@ -73,8 +73,7 @@ public PhoneNumberFieldSkin(PhoneNumberField field, TextField textField) { callingCodes.setAll(temp); if (field.getCountryCallingCode() != null && !temp.contains(field.getCountryCallingCode())) { - // Clear up the value in case the country code is not available anymore - field.setPhoneNumber(null); + field.setRawPhoneNumber(null); // Clear up the value in case the country code is not available anymore } }; @@ -194,7 +193,7 @@ protected void updateItem(PhoneNumberField.CountryCallingCode item, boolean empt int index = -1; if (item != null && !empty) { - setText("(+" + item.phonePrefix() + ") " + new Locale("en", item.iso2Code()).getDisplayCountry()); + setText("(" + item.phonePrefix() + ") " + new Locale("en", item.iso2Code()).getDisplayCountry()); setGraphic(getCountryCodeFlagView(item)); index = getSkinnable().getPreferredCountryCodes().indexOf(item); } else {