diff --git a/README.md b/README.md index 28dadc9..9c87032 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,18 @@ [![Maven Central](https://img.shields.io/maven-central/v/com.dlsc.phonenumberfx/phonenumberfx)](https://search.maven.org/search?q=g:com.dlsc.phonenumberfx%20AND%20a:phonenumberfx) -This repository contains a single control that is being used for entering valid phone numbers +This repository contains a text field control that is being used for entering valid phone numbers for any country in the world. The library has a dependency to [Google's _libphonenumber_ library](https://github.com/google/libphonenumber/), -which is rather big in size, hence we decided to distribute this control via its own project on -GitHub (as opposed to adding it to the GemsFX project.) +which is rather big, hence we decided to distribute this control via its own project on GitHub +(as opposed to adding it to the GemsFX project). The text field can be configured to format +the number on a "value commit" event (focus lost, enter pressed) or constantly while the user is +typing. -The demo website of Google's library [can be found here](https://libphonenumber.appspot.com). +A great tutorial for this control [can be found here](https://coderscratchpad.com/javafx-phone-number-input-field/). + +The second control in this project is a specialized label that properly formats phone numbers set on +it via its `valueProperty()`. The label supports a "home country" so that phone numbers of the home country +will be shown in their national format, while phone numbers from other countries will be shown including +their country code prefix. -A great tutorial for this control [can be found here](https://coderscratchpad.com/javafx-phone-number-input-field/). \ No newline at end of file +The demo website of Google's library [can be found here](https://libphonenumber.appspot.com). 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 cf2e210..ede32e0 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 @@ -27,16 +27,14 @@ public void start(Stage stage) { new Separator(), PhoneNumberFieldSamples.buildDisabledCountrySelectorSample(), new Separator(), - PhoneNumberFieldSamples.buildExpectedPhoneNumberTypeSample(), - new Separator(), - PhoneNumberFieldSamples.buildCountryCodeVisibleSample() + PhoneNumberFieldSamples.buildExpectedPhoneNumberTypeSample() ); ScrollPane scrollPane = new ScrollPane(); scrollPane.setContent(vBox); stage.setTitle("PhoneNumberField"); - stage.setScene(new Scene(scrollPane, 900, 800)); + stage.setScene(new Scene(scrollPane)); stage.sizeToScene(); stage.centerOnScreen(); stage.show(); diff --git a/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldSamples.java b/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldSamples.java index 1ad070f..c69e9d7 100644 --- a/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldSamples.java +++ b/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/PhoneNumberFieldSamples.java @@ -1,12 +1,14 @@ package com.dlsc.phonenumberfx.demo; import com.dlsc.phonenumberfx.PhoneNumberField; +import com.dlsc.phonenumberfx.PhoneNumberField.Country; import com.dlsc.phonenumberfx.PhoneNumberLabel; import com.google.i18n.phonenumbers.PhoneNumberUtil; import javafx.beans.binding.Bindings; import javafx.beans.value.ObservableValue; import javafx.geometry.Pos; import javafx.scene.Node; +import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; @@ -22,7 +24,7 @@ public final class PhoneNumberFieldSamples { if (c == null) { return null; } - PhoneNumberField.Country code = (PhoneNumberField.Country) c; + Country code = (Country) c; return "(" + code.phonePrefix() + ")" + code; }; @@ -41,7 +43,7 @@ public static Node buildDefaultEmptySample() { public static Node buildDefaultPrefilledSample() { PhoneNumberField field = new PhoneNumberField(); - field.setRawPhoneNumber("+573003767182"); + field.setText("+573003767182"); String title = "Initial Value"; String description = "A control with default settings and a value set through code."; @@ -52,11 +54,11 @@ public static Node buildDefaultPrefilledSample() { public static Node buildCustomAvailableCountriesSample() { PhoneNumberField field = new PhoneNumberField(); field.getAvailableCountries().setAll( - PhoneNumberField.Country.COLOMBIA, - PhoneNumberField.Country.GERMANY, - PhoneNumberField.Country.UNITED_STATES, - PhoneNumberField.Country.UNITED_KINGDOM, - PhoneNumberField.Country.SWITZERLAND); + Country.COLOMBIA, + Country.GERMANY, + Country.UNITED_STATES, + Country.UNITED_KINGDOM, + Country.SWITZERLAND); String title = "Available Countries (Customized)"; String description = "A control with modified list of available countries."; @@ -68,9 +70,9 @@ public static Node buildPreferredCountriesSample() { PhoneNumberField field = new PhoneNumberField(); field.getPreferredCountries().setAll( - PhoneNumberField.Country.SWITZERLAND, - PhoneNumberField.Country.GERMANY, - PhoneNumberField.Country.UNITED_KINGDOM); + Country.SWITZERLAND, + Country.GERMANY, + Country.UNITED_KINGDOM); String title = "Preferred Countries"; String description = "Preferred countries all shown at the top of the list always."; @@ -80,7 +82,7 @@ public static Node buildPreferredCountriesSample() { public static Node buildDisabledCountrySelectorSample() { PhoneNumberField field = new PhoneNumberField(); - field.setSelectedCountry(PhoneNumberField.Country.GERMANY); + field.setSelectedCountry(Country.GERMANY); field.setDisableCountryDropdown(true); field.setExpectedPhoneNumberType(PhoneNumberUtil.PhoneNumberType.PERSONAL_NUMBER); @@ -93,7 +95,7 @@ public static Node buildDisabledCountrySelectorSample() { public static Node buildExpectedPhoneNumberTypeSample() { PhoneNumberField field = new PhoneNumberField(); field.setExpectedPhoneNumberType(PhoneNumberUtil.PhoneNumberType.MOBILE); - field.setSelectedCountry(PhoneNumberField.Country.COLOMBIA); + field.setSelectedCountry(Country.COLOMBIA); String title = "Fixed Phone Number Type (MOBILE)"; String description = "Establish an expected phone number type, performs validations against the type and shows an example of the phone number."; @@ -101,16 +103,6 @@ public static Node buildExpectedPhoneNumberTypeSample() { return buildSample(title, description, field); } - public static Node buildCountryCodeVisibleSample() { - PhoneNumberField field = new PhoneNumberField(); - field.setCountryCodeVisible(true); - - String title = "Country Code Visible"; - String description = "Makes the country code always visible in the text field."; - - return buildSample(title, description, field); - } - public static Node buildSample(String title, String description, PhoneNumberField field) { Label titleLabel = new Label(title); titleLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 1.4em;"); @@ -136,13 +128,23 @@ public static Node buildSample(String title, String description, PhoneNumberFiel rightBox.setPrefWidth(400); PhoneNumberLabel phoneNumberLabel = new PhoneNumberLabel(); - phoneNumberLabel.rawPhoneNumberProperty().bind(field.rawPhoneNumberProperty()); + phoneNumberLabel.valueProperty().bind(field.e164PhoneNumberProperty()); + ComboBox countryBox = new ComboBox<>(); + countryBox.getItems().setAll(Country.values()); + countryBox.getSelectionModel().select(field.getSelectedCountry()); + countryBox.valueProperty().bindBidirectional(phoneNumberLabel.countryProperty()); + + VBox labelBox = new VBox(10, phoneNumberLabel, countryBox); + + addField(rightBox, "Text", field.textProperty()); + addField(rightBox, "Value", field.valueProperty()); addField(rightBox, "Country Code", field.selectedCountryProperty(), COUNTRY_CODE_CONVERTER); - addField(rightBox, "Raw Number", field.rawPhoneNumberProperty()); addField(rightBox, "E164 Format", field.e164PhoneNumberProperty()); addField(rightBox, "National Format", field.nationalPhoneNumberProperty()); - addField(rightBox, "PhoneNumberLabel", phoneNumberLabel); + addField(rightBox, "International Format", field.internationalPhoneNumberProperty()); + addField(rightBox, "PhoneNumberLabel", labelBox); + addField(rightBox, "Error Type", field.parsingErrorTypeProperty()); addField(rightBox, "Valid", field.validProperty()); HBox hBox = new HBox(30); @@ -170,11 +172,15 @@ private static void addField(GridPane pane, String name, ObservableValue valu valueLbl.setStyle("-fx-font-family: monospace; -fx-font-size: 1.2em; -fx-font-weight: bold; -fx-padding: 0 0 0 10;"); } - private static void addField(GridPane pane, String name, Label valueLbl) { - valueLbl.setStyle("-fx-font-family: monospace; -fx-font-size: 1.2em; -fx-font-weight: bold; -fx-padding: 0 0 0 10;"); + private static void addField(GridPane pane, String name, Node node) { + if (node instanceof VBox) { + node.setStyle("-fx-border-color: black; -fx-border-width: 1px; -fx-font-family: monospace; -fx-font-size: 1.2em; -fx-font-weight: bold; -fx-padding: 0 0 0 10;"); + } else { + node.setStyle("-fx-font-family: monospace; -fx-font-size: 1.2em; -fx-font-weight: bold; -fx-padding: 0 0 0 10;"); + } int row = pane.getRowCount(); pane.add(new Label(name + ":"), 0, row); - pane.add(valueLbl, 1, row); + pane.add(node, 1, row); } } diff --git a/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/SinglePhoneNumberFieldApp.java b/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/SinglePhoneNumberFieldApp.java index da12a2d..83d0835 100644 --- a/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/SinglePhoneNumberFieldApp.java +++ b/phonenumberfx-demo/src/main/java/com/dlsc/phonenumberfx/demo/SinglePhoneNumberFieldApp.java @@ -1,18 +1,22 @@ package com.dlsc.phonenumberfx.demo; import com.dlsc.phonenumberfx.PhoneNumberField; +import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.PhoneNumberUtil; import javafx.application.Application; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.CheckBox; -import javafx.scene.control.ComboBox; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.Separator; +import javafx.scene.control.*; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.stage.Stage; +import javafx.util.converter.DateTimeStringConverter; +import org.scenicview.ScenicView; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; public class SinglePhoneNumberFieldApp extends Application { @@ -24,7 +28,7 @@ public void start(Stage stage) { PhoneNumberField field = new PhoneNumberField(); vBox.getChildren().addAll( - PhoneNumberFieldSamples.buildSample("Phone Number Field", "A configurable field for entering international phone numbers.", field) + PhoneNumberFieldSamples.buildSample("Phone Number Field", "A configurable field for entering international phone numbers.", field) ); Button clearButton = new Button("Clear"); @@ -33,7 +37,7 @@ public void start(Stage stage) { CheckBox showExampleBox = new CheckBox("Show example number for selected country"); showExampleBox.selectedProperty().bindBidirectional(field.showExampleNumbersProperty()); - CheckBox countryCodeVisibleBox = new CheckBox("Show country code as part of number"); + CheckBox countryCodeVisibleBox = new CheckBox("Show country code"); countryCodeVisibleBox.selectedProperty().bindBidirectional(field.countryCodeVisibleProperty()); CheckBox disableCountryCodeBox = new CheckBox("Disable country dropdown"); @@ -45,6 +49,9 @@ public void start(Stage stage) { CheckBox editableBox = new CheckBox("Editable"); editableBox.selectedProperty().bindBidirectional(field.editableProperty()); + CheckBox liveFormatting = new CheckBox("Live Formatting"); + liveFormatting.selectedProperty().bindBidirectional(field.liveFormattingProperty()); + ComboBox expectedTypeBox = new ComboBox<>(); expectedTypeBox.getItems().setAll(PhoneNumberUtil.PhoneNumberType.values()); expectedTypeBox.valueProperty().bindBidirectional(field.expectedPhoneNumberTypeProperty()); @@ -53,13 +60,28 @@ public void start(Stage stage) { countryBox.getItems().setAll(PhoneNumberField.Country.values()); countryBox.valueProperty().bindBidirectional(field.selectedCountryProperty()); - vBox.getChildren().addAll(new Separator(), clearButton, countryBox, expectedTypeBox, showExampleBox, countryCodeVisibleBox, showCountryCodeBox, disableCountryCodeBox, editableBox); + Button loadSwissNumber = new Button("Swiss number: +41798002320"); + loadSwissNumber.setOnAction(evt -> field.setValue("+410798002320")); + + Button loadUSNumber1 = new Button("Canadian number: +15872223333"); + loadUSNumber1.setOnAction(evt -> field.setValue("+15871234567")); + + Button loadUSNumber2 = new Button("White house: +12024561111"); + loadUSNumber2.setOnAction(evt -> field.setValue("+12024561111")); + + Button loadBadly = new Button("Bad input: 2024561111"); + loadBadly.setOnAction(evt -> field.setValue("2024561111")); + + HBox loaderBox = new HBox(10, loadSwissNumber, loadUSNumber1, loadUSNumber2, loadBadly); + vBox.getChildren().addAll(new Separator(), clearButton, countryBox, expectedTypeBox, liveFormatting, showExampleBox, countryCodeVisibleBox, showCountryCodeBox, disableCountryCodeBox, editableBox, loaderBox); ScrollPane scrollPane = new ScrollPane(); scrollPane.setContent(vBox); + Scene scene = new Scene(scrollPane); + stage.setTitle("PhoneNumberField"); - stage.setScene(new Scene(scrollPane, 900, 400)); + stage.setScene(scene); stage.sizeToScene(); stage.centerOnScreen(); stage.show(); diff --git a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/ErrorTypeConverter.java b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/ErrorTypeConverter.java new file mode 100644 index 0000000..9246420 --- /dev/null +++ b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/ErrorTypeConverter.java @@ -0,0 +1,33 @@ +package com.dlsc.phonenumberfx; + +import com.google.i18n.phonenumbers.NumberParseException; +import javafx.util.StringConverter; + +/** + * Converts parsing error types to human-readable text. + */ +public class ErrorTypeConverter extends StringConverter { + + @Override + public String toString(NumberParseException.ErrorType errorType) { + if (errorType != null) { + switch (errorType) { + case INVALID_COUNTRY_CODE: + return "Invalid country code"; + case NOT_A_NUMBER: + return "Invalid: not a number"; + case TOO_SHORT_AFTER_IDD: + case TOO_SHORT_NSN: + return "Invalid: too short / not enough digits"; + case TOO_LONG: + return "Invalid: too long / too many digits"; + } + } + return null; + } + + @Override + public NumberParseException.ErrorType fromString(String string) { + return null; + } +} diff --git a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java index ba77ff8..db9feab 100644 --- a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java +++ b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberField.java @@ -2,49 +2,29 @@ import com.google.i18n.phonenumbers.AsYouTypeFormatter; import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.NumberParseException.ErrorType; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; import javafx.application.Platform; import javafx.beans.InvalidationListener; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ListProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyBooleanProperty; -import javafx.beans.property.ReadOnlyBooleanWrapper; -import javafx.beans.property.ReadOnlyObjectProperty; -import javafx.beans.property.ReadOnlyObjectWrapper; -import javafx.beans.property.ReadOnlyStringProperty; -import javafx.beans.property.ReadOnlyStringWrapper; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleListProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; -import javafx.beans.value.ChangeListener; +import javafx.beans.binding.Bindings; +import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Bounds; import javafx.geometry.Insets; import javafx.scene.Node; -import javafx.scene.control.ComboBox; -import javafx.scene.control.ContentDisplay; -import javafx.scene.control.ListCell; -import javafx.scene.control.ListView; -import javafx.scene.control.Skin; -import javafx.scene.control.TextFormatter; +import javafx.scene.control.*; import javafx.scene.control.skin.ComboBoxListViewSkin; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; -import javafx.scene.layout.Background; -import javafx.scene.layout.BackgroundFill; -import javafx.scene.layout.CornerRadii; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; +import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.util.Callback; +import javafx.util.StringConverter; import org.controlsfx.control.textfield.CustomTextField; import java.util.ArrayList; @@ -56,16 +36,18 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.TreeMap; import java.util.TreeSet; import java.util.function.UnaryOperator; /** - * A control for entering phone numbers. By default, the phone numbers are expressed in international format, - * including the country calling code and delivered by {@link #rawPhoneNumberProperty()}. - * Another property returns the phone number in the international standard format E164. - * The control supports a list of {@link #getAvailableCountries() available countries} and a list of - * {@link #getPreferredCountries() preferred countries}. + * A control for entering phone numbers. The control supports a list of {@link #getAvailableCountries() available countries} + * and a list of {@link #getPreferredCountries() preferred countries}. To set a phone number the application has to invoke + * the {@link #setValue(String)} method. + * + * @see #getE164PhoneNumber() + * @see #getNationalPhoneNumber() + * @see #getInternationalPhoneNumber() + * @see #getPhoneNumber() */ public class PhoneNumberField extends CustomTextField { @@ -93,20 +75,49 @@ public class PhoneNumberField extends CustomTextField { */ public static final String DEFAULT_STYLE_CLASS = "phone-number-field"; - private final CountryResolver resolver; - private final PhoneNumberFormatter formatter; private final PhoneNumberUtil phoneNumberUtil; private final ComboBox comboBox; private final ObservableList countries = FXCollections.observableArrayList(); /** - * Builds a new phone number field with the default settings. The available country - * calling codes are defined on {@link Country}. + * Constructs a new phone number field with no initial phone number. */ public PhoneNumberField() { + this(null); + } + + /** + * Constructs a new phone number field showing the specified initial phone number. + * + * @param value the "model" / the raw number ideally in form of a complete e164 number (e.g. "+12024561111" for the White House) + */ + public PhoneNumberField(String value) { getStyleClass().add(DEFAULT_STYLE_CLASS); getAvailableCountries().setAll(Country.values()); + phoneNumberUtil = PhoneNumberUtil.getInstance(); + + addEventFilter(KeyEvent.KEY_PRESSED, e -> { + if (e.getCode() == KeyCode.BACK_SPACE + && (getText() == null || getText().isEmpty()) + && getSelectedCountry() != null + && isCountryCodeVisible() + && !getDisableCountryDropdown()) { + + // Clear the country if the user deletes the entire text + clear(); + e.consume(); + } + }); + + Label countryCodePrefixLabel = new Label(); + countryCodePrefixLabel.getStyleClass().add("country-code-prefix-label"); + countryCodePrefixLabel.textProperty().bind(Bindings.createStringBinding(() -> getSelectedCountry() != null ? getSelectedCountry().countryCodePrefix() : null, selectedCountryProperty())); + countryCodePrefixLabel.visibleProperty().bind(countryCodePrefixLabel.textProperty().isNotEmpty().and(countryCodeVisibleProperty())); + countryCodePrefixLabel.managedProperty().bind(countryCodePrefixLabel.textProperty().isNotEmpty().and(countryCodeVisibleProperty())); + countryCodePrefixLabel.setMaxHeight(Double.MAX_VALUE); + countryCodePrefixLabel.setMinWidth(Region.USE_PREF_SIZE); + comboBox = new ComboBox<>() { @Override protected Skin createDefaultSkin() { @@ -152,7 +163,11 @@ protected void layoutChildren(double x, double y, double w, double h) { ButtonCell buttonCell = new ButtonCell(); comboBox.setButtonCell(buttonCell); - setLeft(comboBox); + HBox leftBox = new HBox(comboBox, countryCodePrefixLabel); + leftBox.getStyleClass().add("left-box"); + leftBox.setMaxWidth(Region.USE_PREF_SIZE); + + setLeft(leftBox); addEventFilter(KeyEvent.KEY_PRESSED, evt -> { if (evt.getCode().isLetterKey()) { @@ -167,43 +182,55 @@ protected void layoutChildren(double x, double y, double w, double h) { } }); - phoneNumberUtil = PhoneNumberUtil.getInstance(); - resolver = new CountryResolver(); - formatter = new PhoneNumberFormatter(); - - InvalidationListener updateSampleListener = it -> updatePromptTextWithExampleNumber(); - showExampleNumbersProperty().addListener(updateSampleListener); - expectedPhoneNumberTypeProperty().addListener(updateSampleListener); - selectedCountryProperty().addListener(updateSampleListener); - - selectedCountryProperty().addListener((obs, oldCountry, newCountry) -> { - // Important to execute, or we end up with partial country codes, e.g. "+4" when - // the user deletes numbers via backspace. - if (newCountry == null) { - Platform.runLater(() -> { - setRawPhoneNumber(null); - }); - } - }); + InvalidationListener updatePromptListener = it -> updatePromptTextWithExampleNumber(); + showExampleNumbersProperty().addListener(updatePromptListener); + expectedPhoneNumberTypeProperty().addListener(updatePromptListener); + selectedCountryProperty().addListener(updatePromptListener); + countryCodeVisibleProperty().addListener(updatePromptListener); + missingCountryPromptTextProperty().addListener(updatePromptListener); + + updatePromptTextWithExampleNumber(); showCountryDropdownProperty().addListener(it -> requestLayout()); // important + countryCodeVisibleProperty().addListener(it -> requestLayout()); // important + + valueProperty().addListener((obs, oldV, newV) -> { + phoneNumber.set(null); + nationalPhoneNumber.set(null); + internationalPhoneNumber.set(null); + e164PhoneNumber.set(null); + valid.set(false); + parsingErrorType.set(null); + + if (newV != null && !newV.isEmpty()) { + String regionCode = getRegionCode(newV); + + try { + Phonenumber.PhoneNumber number = phoneNumberUtil.parse(newV, regionCode); + phoneNumber.set(number); + e164PhoneNumber.set(phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164)); + nationalPhoneNumber.set(phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); + internationalPhoneNumber.set(phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)); + valid.set(phoneNumberUtil.isValidNumber(number) && isExpectedTypeMatch(number)); + setTooltip(new Tooltip(getE164PhoneNumber())); + } catch (NumberParseException e) { + parsingErrorType.set(e.getErrorType()); + } + } + }); - phoneNumberProperty().addListener((obs, oldV, newV) -> { - if (newV == null) { - setE164PhoneNumber(null); - setNationalPhoneNumber(null); - setValid(false); - } else { - setE164PhoneNumber(phoneNumberUtil.format(newV, PhoneNumberUtil.PhoneNumberFormat.E164)); - setNationalPhoneNumber(phoneNumberUtil.format(newV, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); - setValid(phoneNumberUtil.isValidNumber(newV) && isExpectedTypeMatch(newV)); + parsingErrorTypeProperty().addListener(it -> { + ErrorType error = getParsingErrorType(); + if (error != null) { + StringConverter converter = getErrorTypeConverter(); + if (converter != null) { + setTooltip(new Tooltip(converter.toString(error))); + } else { + setTooltip(new Tooltip(error.toString())); + } } }); - // Platform.runLater() is important or formatting will not work - ChangeListener updateFormattedPhoneNumberListener = (obs, oldV, newV) -> Platform.runLater(() -> formatter.setFormattedPhoneNumber(getRawPhoneNumber())); - rawPhoneNumberProperty().addListener(updateFormattedPhoneNumberListener); - countryCodeVisibleProperty().addListener(updateFormattedPhoneNumberListener); validProperty().addListener((obs, oldV, newV) -> pseudoClassStateChanged(INVALID_PSEUDO_CLASS, !newV)); countryCellFactory.addListener((obs, oldValue, newValue) -> { @@ -223,6 +250,189 @@ protected void layoutChildren(double x, double y, double w, double h) { validProperty().addListener(it -> updateValidPseudoState()); updateValidPseudoState(); + + final UnaryOperator filter = c -> { + String text = c.getText(); + + if (text.equals(c.getControlText())) { + // no change means all good + return c; + } + + if (!text.isEmpty()) { + String controlNewText = c.getControlNewText(); + if (controlNewText.startsWith("+")) { + if (controlNewText.length() > 1) { + Country country = Country.ofCountryCodePrefix(controlNewText); + if (country != null) { + Platform.runLater(() -> { + setText(""); + setSelectedCountry(country); + }); + } + } + + return c; + } + + char ch = text.charAt(0); + if (Character.isDigit(ch) || ch == '(' || ch == ')' || ch == '-' || ch == '.' || ch == ' ') { + return c; + } + + return null; + } + + return c; + }; + + StringConverter converter = new StringConverter<>() { + + @Override + public String toString(String value) { + if (value != null && !value.trim().isEmpty()) { + value = value.trim(); + + if (isLiveFormatting()) { + return toStringLiveFormatting(value); + } + + return toStringOnCommitFormatting(value); + } + + return value; + } + + private String toStringLiveFormatting(String value) { + String regionCode = getRegionCode(value); + AsYouTypeFormatter asYouTypeFormatter = phoneNumberUtil.getAsYouTypeFormatter(regionCode); + String result = ""; + for (int i = 0; i < value.length(); i++) { + result = asYouTypeFormatter.inputDigit(value.charAt(i)); + } + + return result.substring(getSelectedCountry().countryCodePrefix().length()).trim(); + } + + private String toStringOnCommitFormatting(String value) { + String regionCode = getRegionCode(value); + + try { + Phonenumber.PhoneNumber phoneNumber = phoneNumberUtil.parse(value, regionCode); + + Country country = Country.ofPhoneNumber(phoneNumber); + setSelectedCountry(country); + + if (!phoneNumberUtil.isValidNumber(phoneNumber)) { + return formatAsYouType(value, country); + } + + // a valid number + if (isCountryCodeVisible()) { + // use the international formatting WITHOUT local prefix, e.g "0" in Germany + String prefix = country.countryCodePrefix(); + return phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL).substring(prefix.length()).trim(); + } else { + // use the national formatting WITH local prefix, e.g "0" in Germany + return phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL); + } + } catch (NumberParseException e) { + // no-op + } + + return value; + } + + @Override + public String fromString(String textFieldString) { + if (getSelectedCountry() != null) { + try { + Phonenumber.PhoneNumber phoneNumber = phoneNumberUtil.parse(textFieldString, getSelectedCountry().iso2Code()); + return phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + } catch (NumberParseException e) { + parsingErrorType.set(e.getErrorType()); + } + } + + return null; + } + }; + + countryCodeVisibleProperty().addListener(it -> commitValue()); + + TextFormatter formatter = new TextFormatter<>(converter, null, filter); + formatter.valueProperty().bindBidirectional(valueProperty()); + + textProperty().addListener(it -> { + if (isLiveFormatting()) { + if (!updating) { + Platform.runLater(() -> { + updating = true; + try { + doCommitValue(); + } finally { + updating = false; + } + }); + } + } + }); + + setTextFormatter(formatter); + + if (value != null && !value.trim().isEmpty()) { + setValue(value); + } + } + + private String formatAsYouType(String value, Country country) { + // not a valid number, yet, so let's use the AsYouType formatter + AsYouTypeFormatter asYouTypeFormatter = phoneNumberUtil.getAsYouTypeFormatter(country.iso2Code()); + String result = ""; + for (int i = 0; i < value.length(); i++) { + result = asYouTypeFormatter.inputDigit(value.charAt(i)); + } + return result.substring(country.countryCodePrefix().length()).trim(); + } + + private String getRegionCode(String value) { + try { + if (value.startsWith("+")) { + Phonenumber.PhoneNumber number = PhoneNumberUtil.getInstance().parse(value, Country.ofDefaultLocale().iso2Code()); + Country country = Country.ofPhoneNumber(number); + setSelectedCountry(country); + return country.iso2Code(); + } + } catch (NumberParseException ex) { + // do nothing + } + + Country country = getSelectedCountry(); + if (country != null) { + return country.iso2Code(); + } + + return null; + } + + private synchronized void doCommitValue() { + commitValue(); + } + + private boolean updating = false; + + private final BooleanProperty liveFormatting = new SimpleBooleanProperty(this, "liveFormatting", false); + + public final boolean isLiveFormatting() { + return liveFormatting.get(); + } + + public final BooleanProperty liveFormattingProperty() { + return liveFormatting; + } + + public final void setLiveFormatting(boolean liveFormatting) { + this.liveFormatting.set(liveFormatting); } private void updateCountryList() { @@ -247,7 +457,16 @@ private void updateCountryList() { countries.setAll(temp); if (getSelectedCountry() != null && !temp.contains(getSelectedCountry())) { - setRawPhoneNumber(null); // Clear up the value in case the country code is not available anymore + clear(); // Clear up the value in case the country code is not available anymore + } + } + + @Override + public void clear() { + super.clear(); + + if (isShowCountryDropdown() || isCountryCodeVisible()) { + setSelectedCountry(null); } } @@ -263,31 +482,53 @@ private void showCountry(Country country) { if (country != null) { listView.scrollTo(country); - listView.getSelectionModel().select(country); + listView.getFocusModel().focus(listView.getItems().indexOf(country)); + listView.requestFocus(); } } + private final StringProperty missingCountryPromptText = new SimpleStringProperty(this, "missingCountryPromptText", "Select a country ..."); + + public final String getMissingCountryPromptText() { + return missingCountryPromptText.get(); + } + + /** + * Specifies a prompt text that will be shown when no country has been selected, yet. + */ + public final StringProperty missingCountryPromptTextProperty() { + return missingCountryPromptText; + } + + public final void setMissingCountryPromptText(String missingCountryPromptText) { + this.missingCountryPromptText.set(missingCountryPromptText); + } + private void updatePromptTextWithExampleNumber() { - if (isShowExampleNumbers()) { + if (!promptTextProperty().isBound()) { if (getSelectedCountry() == null) { - setPromptText(null); + setPromptText(getMissingCountryPromptText()); } else { - Phonenumber.PhoneNumber sampleNumber; - if (getExpectedPhoneNumberType() == null) { - sampleNumber = phoneNumberUtil.getExampleNumber(getSelectedCountry().iso2Code()); - } else { - sampleNumber = phoneNumberUtil.getExampleNumberForType(getSelectedCountry().iso2Code(), getExpectedPhoneNumberType()); - } - if (sampleNumber != null) { - setPromptText(formatter.doFormat(phoneNumberUtil.format(sampleNumber, PhoneNumberUtil.PhoneNumberFormat.E164), getSelectedCountry())); + if (isShowExampleNumbers()) { + if (getSelectedCountry() == null) { + setPromptText(null); + } else { + Phonenumber.PhoneNumber sampleNumber; + if (getExpectedPhoneNumberType() == null) { + sampleNumber = phoneNumberUtil.getExampleNumber(getSelectedCountry().iso2Code()); + } else { + sampleNumber = phoneNumberUtil.getExampleNumberForType(getSelectedCountry().iso2Code(), getExpectedPhoneNumberType()); + } + if (sampleNumber != null) { + setPromptText(phoneNumberUtil.format(sampleNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); + } else { + setPromptText(""); + } + } } else { - setPromptText(""); + setPromptText(null); } } - } else { - if (!promptTextProperty().isBound()) { - setPromptText(null); - } } } @@ -300,12 +541,6 @@ public String getUserAgentStylesheet() { return Objects.requireNonNull(PhoneNumberField.class.getResource("phone-number-field.css")).toExternalForm(); } - @Override - public void clear() { - super.clear(); - setRawPhoneNumber(null); - } - private class ButtonCell extends ListCell { public ButtonCell() { @@ -323,59 +558,12 @@ protected void updateItem(Country country, boolean empty) { } } - // VALUES - - private final StringProperty rawPhoneNumber = new SimpleStringProperty(this, "rawPhoneNumber") { - private boolean selfUpdate; - - @Override - public void set(String newRawPhoneNumber) { - errorType.set(null); - - if (selfUpdate) { - return; - } - - try { - selfUpdate = true; - - // Set the value first, so that the binding will be triggered - super.set(newRawPhoneNumber); - - // Resolve all dependencies out of the raw phone number - Country country = resolver.call(newRawPhoneNumber); + // PARSING ERROR TYPE - if (country != null) { - setSelectedCountry(country); - formatter.setFormattedPhoneNumber(newRawPhoneNumber); - - try { - Phonenumber.PhoneNumber number = phoneNumberUtil.parse(newRawPhoneNumber, country.iso2Code()); - setPhoneNumber(number); - } catch (NumberParseException e) { - errorType.set(e.getErrorType()); - setPhoneNumber(null); - } catch (Exception e) { - setPhoneNumber(null); - } - } else { - setSelectedCountry(null); - formatter.setFormattedPhoneNumber(null); - setPhoneNumber(null); - } - - } finally { - selfUpdate = false; - } - } - }; - - // ERROR TYPE - - private final ReadOnlyObjectWrapper errorType = new ReadOnlyObjectWrapper<>(this, "errorType"); + private final ReadOnlyObjectWrapper parsingErrorType = new ReadOnlyObjectWrapper<>(this, "parsingErrorType"); - public final NumberParseException.ErrorType getErrorType() { - return errorType.get(); + public final ErrorType getParsingErrorType() { + return parsingErrorType.get(); } /** @@ -385,68 +573,64 @@ public final NumberParseException.ErrorType getErrorType() { * * @return the error type property */ - public final ReadOnlyObjectProperty errorTypeProperty() { - return errorType.getReadOnlyProperty(); + public final ReadOnlyObjectProperty parsingErrorTypeProperty() { + return parsingErrorType.getReadOnlyProperty(); } - // RAW PHONE NUMBER + // SELECTED COUNTRY + + private final ObjectProperty selectedCountry = new SimpleObjectProperty<>(this, "selectedCountry", Country.ofDefaultLocale()); /** - * @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. + * The selected country. Use this property if you want to define a default (pre-selected) country. + * It can also be used in conjunction with {@link #disableCountryDropdownProperty()} to avoid + * changing the country part. */ - public final StringProperty rawPhoneNumberProperty() { - return rawPhoneNumber; + public final ObjectProperty selectedCountryProperty() { + return selectedCountry; } - public final String getRawPhoneNumber() { - return rawPhoneNumberProperty().get(); + public final Country getSelectedCountry() { + return selectedCountryProperty().get(); } - public final void setRawPhoneNumber(String rawPhoneNumber) { - rawPhoneNumberProperty().set(rawPhoneNumber); + public final void setSelectedCountry(Country selectedCountry) { + selectedCountryProperty().set(selectedCountry); } - // SELECTED COUNTRY + private final StringProperty value = new SimpleStringProperty(this, "value"); - private final ObjectProperty selectedCountry = new SimpleObjectProperty<>(this, "selectedCountry") { - private boolean selfUpdate; + public final String getValue() { + return value.get(); + } - @Override - public void set(Country newCountry) { - if (selfUpdate) { - return; - } + /** + * The value of the field is the information put into the field from the outside / from the + * application. The value property is the model of the phone number field. + * + * @return the value of the field, which is the raw number passed into the field from the application + */ + public final StringProperty valueProperty() { + return value; + } - try { - selfUpdate = true; + public final void setValue(String value) { + this.value.set(value); + } - // Set the value first, so that the binding will be triggered - super.set(newCountry); + // INTERNATIONAL PHONE NUMBER - setRawPhoneNumber(newCountry == null ? null : newCountry.phonePrefix()); + private final ReadOnlyStringWrapper internationalPhoneNumber = new ReadOnlyStringWrapper(this, "internationalPhoneNumber"); - } finally { - selfUpdate = false; - } - } - }; + public final String getInternationalPhoneNumber() { + return internationalPhoneNumber.get(); + } /** - * @return The selected country. Use this property if you want to define a default (pre-selected) country. - * It can also be used in conjunction with {@link #disableCountryDropdownProperty()} to avoid - * changing the country part. + * The {@link #phoneNumberProperty() phone number} formatted as an international number including the country code. */ - public final ObjectProperty selectedCountryProperty() { - return selectedCountry; - } - - public final Country getSelectedCountry() { - return selectedCountryProperty().get(); - } - - public final void setSelectedCountry(Country selectedCountry) { - selectedCountryProperty().set(selectedCountry); + public final ReadOnlyStringProperty internationalPhoneNumberProperty() { + return internationalPhoneNumber.getReadOnlyProperty(); } // NATIONAL PHONE NUMBER @@ -454,7 +638,7 @@ public final void setSelectedCountry(Country selectedCountry) { private final ReadOnlyStringWrapper nationalPhoneNumber = new ReadOnlyStringWrapper(this, "nationalPhoneNumber"); /** - * @return The {@link #phoneNumberProperty() phone number} formatted as a national number without the country code. + * The {@link #phoneNumberProperty() phone number} formatted as a national number without the country code. */ public final ReadOnlyStringProperty nationalPhoneNumberProperty() { return nationalPhoneNumber.getReadOnlyProperty(); @@ -464,16 +648,12 @@ public final String getNationalPhoneNumber() { return nationalPhoneNumber.get(); } - private void setNationalPhoneNumber(String nationalPhoneNumber) { - this.nationalPhoneNumber.set(nationalPhoneNumber); - } - // E164 PHONE NUMBER private final ReadOnlyStringWrapper e164PhoneNumber = new ReadOnlyStringWrapper(this, "e164PhoneNumber"); /** - * @return The {@link #phoneNumberProperty() phone number} formatted as E164 standard format including the country code and national number. + * The {@link #phoneNumberProperty() phone number} formatted as E164 standard format including the country code and national number. */ public final ReadOnlyStringProperty e164PhoneNumberProperty() { return e164PhoneNumber.getReadOnlyProperty(); @@ -483,16 +663,12 @@ public final String getE164PhoneNumber() { return e164PhoneNumber.get(); } - private void setE164PhoneNumber(String e164PhoneNumber) { - this.e164PhoneNumber.set(e164PhoneNumber); - } - // PHONE NUMBER AS AN OBJECT private final ReadOnlyObjectWrapper phoneNumber = new ReadOnlyObjectWrapper<>(this, "phoneNumber"); /** - * @return The phone number parsed out from the {@link #rawPhoneNumberProperty() raw phone number}, this might be {@code null} if the + * The phone number parsed out from the {@link #e164PhoneNumberProperty()} () e164 phone number}, this might be {@code null} if the * phone number is not valid. */ public final ReadOnlyObjectProperty phoneNumberProperty() { @@ -503,10 +679,6 @@ public final Phonenumber.PhoneNumber getPhoneNumber() { return phoneNumber.get(); } - private void setPhoneNumber(Phonenumber.PhoneNumber phoneNumber) { - this.phoneNumber.set(phoneNumber); - } - // AVAILABLE COUNTRIES private final ListProperty availableCountries = new SimpleListProperty<>(FXCollections.observableArrayList()); @@ -602,10 +774,6 @@ public final boolean isValid() { return valid.get(); } - private void setValid(boolean valid) { - this.valid.set(valid); - } - // EXPECTED PHONE NUMBER TYPE private final ObjectProperty expectedPhoneNumberType = new SimpleObjectProperty<>(this, "expectedPhoneNumberType"); @@ -635,7 +803,7 @@ private boolean isExpectedTypeMatch(Phonenumber.PhoneNumber number) { // COUNTRY CODE VISIBLE - private final BooleanProperty countryCodeVisible = new SimpleBooleanProperty(this, "countryCodeVisible"); + private final BooleanProperty countryCodeVisible = new SimpleBooleanProperty(this, "countryCodeVisible", true); /** * @return Determines if the country code stays visible in the field or if it gets removed as soon as @@ -734,7 +902,9 @@ public enum Country { BURUNDI(257, "BI"), CAMBODIA(855, "KH"), CAMEROON(237, "CM"), - CANADA(1, "CA"), + CANADA(1, "CA", 204, 226, 236, 249, 250, 263, 289, 306, 343, 365, 367, 368, 403, 416, 418, 431, 437, 438, 450, 468, + 474, 506, 514, 519, 548, 579, 581, 584, 587, 604, 613, 639, 647, 673, 683, 705, 709, 742, 753, 778, 780, 782, + 807, 819, 825, 867, 873, 902, 905), CAPE_VERDE(238, "CV"), CAYMAN_ISLANDS(1, "KY", 345), CENTRAL_AFRICAN_REPUBLIC(236, "CF"), @@ -949,6 +1119,14 @@ public enum Country { this.areaCodes = Optional.ofNullable(areaCodes).orElse(new int[0]); } + public static Country ofDefaultLocale() { + return ofLocale(Locale.getDefault()); + } + + public static Country ofLocale(Locale locale) { + return ofISO2(locale.getCountry()); + } + public static Country ofISO2(String country) { for (Country c : values()) { if (c.iso2Code.equals(country)) { @@ -958,6 +1136,73 @@ public static Country ofISO2(String country) { return null; } + public static Country ofCountryCodePrefix(String prefix) { + try { + int code = Integer.parseInt(prefix.substring(1)); // skip the "+" + + // first try to find the country with no additional area codes, e.g. US = +1 + for (Country c : values()) { + if (c.countryCode == code && c.areaCodes.length == 0) { + return c; + } + } + + // now find a country WITH area codes + for (Country c : values()) { + if (c.countryCode == code) { + return c; + } + } + } catch (NumberFormatException ex) { + // no-op + } + + return null; + } + + public static Country ofPhoneNumber(Phonenumber.PhoneNumber phoneNumber) { + int code = phoneNumber.getCountryCode(); + + String numberText = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + numberText = numberText.substring(("+" + code).length()); + + int geoLength = PhoneNumberUtil.getInstance().getLengthOfNationalDestinationCode(phoneNumber); + if (numberText.length() >= geoLength) { + try { + int nationalDestinationCode = Integer.parseInt(numberText.substring(0, geoLength)); + + for (Country c : values()) { + if (c.countryCode == code) { + if (containsArea(c, nationalDestinationCode)) { + return c; + } + } + } + + for (Country c : values()) { + if (c.countryCode == code && c.areaCodes.length == 0) { + return c; + } + } + } catch (NumberFormatException ex) { + // fallback strategy is to use the country code only and ignore national destination code + return ofCountryCodePrefix("+" + code); + } + } + + // fallback strategy is to use the country code only + return ofCountryCodePrefix("+" + code); + } + + private static boolean containsArea(Country c, int geoCode) { + for (int areaCode : c.areaCodes) { + if (areaCode == geoCode) { + return true; + } + } + return false; + } + public int countryCode() { return countryCode; } @@ -1001,222 +1246,33 @@ private String countryName() { } - /** - * For internal use only. - */ - private final class PhoneNumberFormatter implements UnaryOperator { - - private PhoneNumberFormatter() { - setTextFormatter(new TextFormatter<>(this)); - addEventHandler(KeyEvent.KEY_PRESSED, e -> { - if (e.getCode() == KeyCode.BACK_SPACE - && (getText() == null || getText().isEmpty()) - && getSelectedCountry() != null - && !getDisableCountryDropdown()) { - - // Clear the country if the user deletes the entire text - setRawPhoneNumber(null); - e.consume(); - } - }); - } - - private boolean selfUpdate; - - @Override - public TextFormatter.Change apply(TextFormatter.Change change) { - if (selfUpdate) { - return change; - } - - try { - selfUpdate = true; - Country country = getSelectedCountry(); - - if (change.isAdded()) { - String text = change.getText(); - - if (country == null && text.startsWith("+")) { - text = text.substring(1); - } - - if (!text.isEmpty() && !text.matches("[0-9]+")) { - return null; - } - - if (country == null && !change.getControlNewText().startsWith("+")) { - change.setText("+" + change.getText()); - change.setCaretPosition(change.getCaretPosition() + 1); - change.setAnchor(change.getAnchor() + 1); - } - } - - if (change.isContentChange()) { - if (country == null) { - resolveCountry(change); - } else { - setRawPhoneNumber(undoFormat(change.getControlNewText(), country)); - } - } - - } finally { - selfUpdate = false; - } - - return change; - } - - private void resolveCountry(TextFormatter.Change change) { - Country country = resolver.call(change.getControlNewText()); - if (country != null) { - setSelectedCountry(country); - if (!isCountryCodeVisible()) { - Platform.runLater(() -> { - setText(Optional.ofNullable(country.defaultAreaCode()).map(String::valueOf).orElse("")); - change.setText(""); - change.setCaretPosition(0); - change.setAnchor(0); - change.setRange(0, 0); - }); - } - } - } - - private String doFormat(String newRawPhoneNumber, Country country) { - if (newRawPhoneNumber == null || newRawPhoneNumber.isEmpty() || country == null) { - return ""; - } - - AsYouTypeFormatter formatter = phoneNumberUtil.getAsYouTypeFormatter(country.iso2Code()); - String formattedNumber = ""; - - for (char c : newRawPhoneNumber.toCharArray()) { - formattedNumber = formatter.inputDigit(c); - } - - if (isCountryCodeVisible()) { - return formattedNumber; - } - - return formattedNumber.substring(country.countryCodePrefix().length()).trim(); - } - - private String undoFormat(String formattedPhoneNumber, Country country) { - StringBuilder rawPhoneNumber = new StringBuilder(); - - if (formattedPhoneNumber != null && !formattedPhoneNumber.isEmpty()) { - for (char c : formattedPhoneNumber.toCharArray()) { - if (Character.isDigit(c)) { - rawPhoneNumber.append(c); - } - } - } - - if (!isCountryCodeVisible()) { - rawPhoneNumber.insert(0, country.countryCodePrefix()); - } else { - rawPhoneNumber.insert(0, "+"); - } - - return rawPhoneNumber.toString(); - } - - private void setFormattedPhoneNumber(String newRawPhoneNumber) { - if (selfUpdate) { - return; // Ignore when I'm the one who initiated the update - } - - try { - selfUpdate = true; - String formattedPhoneNumber = doFormat(newRawPhoneNumber, getSelectedCountry()); - setText(formattedPhoneNumber); - positionCaret(formattedPhoneNumber.length()); - } finally { - selfUpdate = false; - } - } + private final ObjectProperty> errorTypeConverter = new SimpleObjectProperty<>(this, "errorTypeConverter", new ErrorTypeConverter()); + public StringConverter getErrorTypeConverter() { + return errorTypeConverter.get(); } /** - * For internal use only. + * Returns the property that represents the converter for the error type. + * The converter is used to convert the error type to a string for display purposes. + * + * @return the property that represents the converter for the error type */ - private final class CountryResolver implements Callback { - - @Override - public Country call(String phoneNumber) { - if (phoneNumber == null || phoneNumber.isEmpty()) { - return null; - } - - if (phoneNumber.startsWith("+")) { - phoneNumber = phoneNumber.substring(1); - if (phoneNumber.isEmpty()) { - return null; - } - } - - TreeMap> scores = new TreeMap<>(); - - for (Country country : getAvailableCountries()) { - int score = calculateScore(country, phoneNumber); - if (score > 0) { - scores.computeIfAbsent(score, s -> new ArrayList<>()).add(country); - } - } - - Map.Entry> highestScore = scores.lastEntry(); - if (highestScore == null) { - return null; - } - - return inferBestMatch(highestScore.getValue()); - } - - private int calculateScore(Country country, String phoneNumber) { - String countryPrefix = String.valueOf(country.countryCode()); - - if (country.areaCodes().length == 0) { - if (phoneNumber.startsWith(countryPrefix)) { - return 1; - } - } else { - for (int areaCode : country.areaCodes()) { - String areaCodePrefix = countryPrefix + areaCode; - if (phoneNumber.startsWith(areaCodePrefix)) { - return 2; - } - } - } - - return 0; - } - - private Country inferBestMatch(List matchingCountries) { - Country code = null; - if (matchingCountries.size() > 1) { - // pick the country that is preferred - for (Country c : matchingCountries) { - if (getPreferredCountries().contains(c)) { - code = c; - break; - } - } + public ObjectProperty> errorTypeConverterProperty() { + return errorTypeConverter; + } - if (code == null) { - code = matchingCountries.get(matchingCountries.size() - 1); - } - } else { - code = matchingCountries.get(0); - } - return code; - } + public void setErrorTypeConverter(StringConverter errorTypeConverter) { + this.errorTypeConverter.set(errorTypeConverter); } - private class CountryCell extends ListCell { + public class CountryCell extends ListCell { private CountryCell() { getStyleClass().add("country-cell"); + + setOnMousePressed(evt -> setValue("")); + setOnTouchPressed(evt -> setValue("")); } @Override diff --git a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberLabel.java b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberLabel.java index ade915f..1d11d8b 100644 --- a/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberLabel.java +++ b/phonenumberfx/src/main/java/com/dlsc/phonenumberfx/PhoneNumberLabel.java @@ -5,6 +5,7 @@ import com.google.i18n.phonenumbers.NumberParseException.ErrorType; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; +import javafx.beans.InvalidationListener; import javafx.beans.property.*; import javafx.css.PseudoClass; import javafx.scene.control.*; @@ -15,8 +16,8 @@ /** * A control for displaying a phone number in a formatted way based on a raw number string. - * - * @see #setRawPhoneNumber(String) + * + * @see #setValue(String) */ public class PhoneNumberLabel extends Label { @@ -47,44 +48,48 @@ public PhoneNumberLabel() { textProperty().addListener(it -> { if (!updatingText) { // somebody called setText(...) instead of setRawPhoneNumber(...) - setRawPhoneNumber(getText()); + setValue(getText()); } }); - rawPhoneNumber.addListener((obs, oldRawPhoneNumber, newRawPhoneNumber) -> { - updatingText = true; + InvalidationListener updateListener = it -> { + String newRawPhoneNumber = getValue(); + updatingText = true; errorType.set(null); + Country country = getCountry(); + if (country == null) { + country = Country.ofDefaultLocale(); + } + try { Phonenumber.PhoneNumber phoneNumber; - if (getCountry() != null) { - phoneNumber = phoneNumberUtil.parse(newRawPhoneNumber, getCountry().iso2Code()); - - e164PhoneNumber.set(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164)); - nationalPhoneNumber.set(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); - - switch (getStrategy()) { - case NATIONAL_FOR_OWN_COUNTRY_ONLY: - if (phoneNumber.getCountryCode() == getCountry().countryCode()) { - setText(getNationalPhoneNumber()); - } else { - setText(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)); - } - break; - case ALWAYS_DISPLAY_INTERNATIONAL: - setText(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)); - break; - case ALWAYS_DISPLAY_NATIONAL: - setText(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); - break; - } - } else { - phoneNumber = phoneNumberUtil.parse(newRawPhoneNumber, Country.UNITED_STATES.iso2Code()); // well, USA is prefix +1 :-) - setText(getRawPhoneNumber()); - e164PhoneNumber.set(""); - nationalPhoneNumber.set(""); + e164PhoneNumber.set(null); + nationalPhoneNumber.set(null); + internationalPhoneNumber.set(null); + + phoneNumber = phoneNumberUtil.parse(newRawPhoneNumber, country.iso2Code()); + + e164PhoneNumber.set(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164)); + nationalPhoneNumber.set(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)); + internationalPhoneNumber.set(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)); + + switch (getStrategy()) { + case NATIONAL_FOR_OWN_COUNTRY_ONLY: + if (phoneNumber.getCountryCode() == country.countryCode()) { + setText(getNationalPhoneNumber()); + } else { + setText(getInternationalPhoneNumber()); + } + break; + case ALWAYS_DISPLAY_INTERNATIONAL: + setText(getInternationalPhoneNumber()); + break; + case ALWAYS_DISPLAY_NATIONAL: + setText(getNationalPhoneNumber()); + break; } setTooltip(new Tooltip(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL))); @@ -93,23 +98,24 @@ public PhoneNumberLabel() { ErrorType errorType = e.getErrorType(); if (errorType != null) { this.errorType.set(errorType); - setTooltip(new Tooltip(getConverter().toString(errorType))); + setTooltip(new Tooltip(getErrorTypeConverter().toString(errorType))); } else { setTooltip(new Tooltip(newRawPhoneNumber)); } - if (newRawPhoneNumber != null && newRawPhoneNumber.startsWith(getCountry().countryCodePrefix())) { - newRawPhoneNumber = newRawPhoneNumber.substring(getCountry().countryCodePrefix().length()); + if (newRawPhoneNumber != null && newRawPhoneNumber.startsWith(country.countryCodePrefix())) { + newRawPhoneNumber = newRawPhoneNumber.substring(country.countryCodePrefix().length()); } - e164PhoneNumber.set(""); - nationalPhoneNumber.set(""); setText(newRawPhoneNumber); setValid(false); } finally { updatingText = false; } - }); + }; + + countryProperty().addListener(updateListener); + value.addListener(updateListener); validProperty().addListener(it -> updateValidPseudoState()); updateValidPseudoState(); @@ -121,34 +127,10 @@ private void updateValidPseudoState() { // STRING CONVERTER - private final ObjectProperty> converter = new SimpleObjectProperty<>(this, "converter", new StringConverter<>() { - - @Override - public String toString(ErrorType errorType) { - if (errorType != null) { - switch (errorType) { - case INVALID_COUNTRY_CODE: - return "Invalid country code"; - case NOT_A_NUMBER: - return "Invalid: not a number"; - case TOO_SHORT_AFTER_IDD: - case TOO_SHORT_NSN: - return "Invalid: too short / not enough digits"; - case TOO_LONG: - return "Invalid: too long / too many digits"; - } - } - return null; - } - - @Override - public ErrorType fromString(String string) { - return null; - } - }); + private final ObjectProperty> errorTypeConverter = new SimpleObjectProperty<>(this, "errorTypeConverter", new ErrorTypeConverter()); - public StringConverter getConverter() { - return converter.get(); + public StringConverter getErrorTypeConverter() { + return errorTypeConverter.get(); } /** @@ -157,12 +139,12 @@ public StringConverter getConverter() { * * @return the property that represents the converter for the error type */ - public ObjectProperty> converterProperty() { - return converter; + public ObjectProperty> errorTypeConverterProperty() { + return errorTypeConverter; } - public void setConverter(StringConverter converter) { - this.converter.set(converter); + public void setErrorTypeConverter(StringConverter errorTypeConverter) { + this.errorTypeConverter.set(errorTypeConverter); } // ERROR TYPE @@ -186,7 +168,7 @@ public final ReadOnlyObjectProperty errorTypeProperty() { /** * Enumeration representing different strategies for displaying phone numbers. - * + * * @see #setStrategy(Strategy) */ public enum Strategy { @@ -230,24 +212,25 @@ public final void setStrategy(Strategy strategy) { this.strategy.set(strategy); } - // RAW PHONE NUMBER + // PHONE NUMBER - private final StringProperty rawPhoneNumber = new SimpleStringProperty(this, "rawPhoneNumber"); + private final StringProperty value = new SimpleStringProperty(this, "value"); /** - * @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. + * The text corresponding exactly to what the user typed in, including the (+) sign at the + * beginning. This value can be a valid E164 formatted number. The label will do its best to properly format the + * given number. */ - public final StringProperty rawPhoneNumberProperty() { - return rawPhoneNumber; + public final StringProperty valueProperty() { + return value; } - public final String getRawPhoneNumber() { - return rawPhoneNumberProperty().get(); + public final String getValue() { + return valueProperty().get(); } - public final void setRawPhoneNumber(String rawPhoneNumber) { - rawPhoneNumberProperty().set(rawPhoneNumber); + public final void setValue(String value) { + valueProperty().set(value); } // COUNTRY @@ -289,6 +272,25 @@ private void setNationalPhoneNumber(String nationalPhoneNumber) { this.nationalPhoneNumber.set(nationalPhoneNumber); } + // NATIONAL PHONE NUMBER + + private final ReadOnlyStringWrapper internationalPhoneNumber = new ReadOnlyStringWrapper(this, "internationalPhoneNumber"); + + /** + * A representation of the phone number in the national format of the specified country. + */ + public final ReadOnlyStringProperty internationalPhoneNumberProperty() { + return internationalPhoneNumber.getReadOnlyProperty(); + } + + public final String getInternationalPhoneNumber() { + return internationalPhoneNumber.get(); + } + + private void setInternationalPhoneNumber(String nationalPhoneNumber) { + this.internationalPhoneNumber.set(nationalPhoneNumber); + } + // E164 PHONE NUMBER private final ReadOnlyStringWrapper e164PhoneNumber = new ReadOnlyStringWrapper(this, "e164PhoneNumber"); diff --git a/phonenumberfx/src/main/resources/com/dlsc/phonenumberfx/phone-number-field.css b/phonenumberfx/src/main/resources/com/dlsc/phonenumberfx/phone-number-field.css index 3af31cc..591daea 100644 --- a/phonenumberfx/src/main/resources/com/dlsc/phonenumberfx/phone-number-field.css +++ b/phonenumberfx/src/main/resources/com/dlsc/phonenumberfx/phone-number-field.css @@ -5,38 +5,47 @@ } .phone-number-field > .left-pane { + -fx-padding: 0px; +} + +.phone-number-field > .left-pane > .left-box { +} + +.phone-number-field > .left-pane > .left-box > .country-code-prefix-label { + -fx-padding: 0px 0px 0px 5px; + -fx-translate-x: 5px; } -.phone-number-field > .left-pane > .combo-box { +.phone-number-field > .left-pane > .left-box > .combo-box { -fx-pref-width: 30px; -fx-background-color: transparent; -fx-padding: 0px; } -.phone-number-field > .left-pane > .combo-box .graphics { +.phone-number-field > .left-pane > .left-box > .combo-box .graphics { -fx-padding: 0px 0px; -fx-alignment: center; } -.phone-number-field > .left-pane > .combo-box .arrow-button { +.phone-number-field > .left-pane > .left-box > .combo-box .arrow-button { -fx-padding: 0px; -fx-background-radius: 0px; } -.phone-number-field > .left-pane > .combo-box > .arrow-button > .arrow { +.phone-number-field > .left-pane > .left-box > .combo-box > .arrow-button > .arrow { -fx-background-color: -fx-text-background-color; } -.phone-number-field > .left-pane > .combo-box > .graphics > .flag-wrapper { +.phone-number-field > .left-pane > .left-box > .combo-box > .graphics > .flag-wrapper { -fx-padding: 1px; -fx-background-color: -fx-text-inner-color; } -.phone-number-field > .left-pane > .combo-box > .globe-button { +.phone-number-field > .left-pane > .left-box > .combo-box > .globe-button { -fx-padding: 5px; } -.phone-number-field > .left-pane > .combo-box > .globe-button > .globe { +.phone-number-field > .left-pane > .left-box > .combo-box > .globe-button > .globe { -size: 1.2em; -fx-pref-width: -size; -fx-pref-height: -size; @@ -50,15 +59,15 @@ -fx-shape: "M0 12 Q0 8.7188 1.5938 5.9688 Q3.2031 3.2031 5.9531 1.6094 Q8.7188 0 12 0 Q15.2812 0 18.0312 1.6094 Q20.7969 3.2031 22.3906 5.9688 Q24 8.7188 24 12 Q24 15.2812 22.3906 18.0469 Q20.7969 20.7969 18.0312 22.4062 Q15.2812 24 12 24 Q8.7188 24 5.9531 22.4062 Q3.2031 20.7969 1.5938 18.0469 Q0 15.2812 0 12 ZM11.2812 1.5938 Q9.6875 2.0781 8.4062 4.4062 Q8.0781 5.0469 7.8438 5.6875 Q9.4375 6.0781 11.2812 6.1562 L11.2812 1.5938 ZM6.4062 5.2812 Q6.7188 4.4844 7.1094 3.7188 Q7.5156 2.9531 8 2.3125 Q6.0781 3.125 4.5625 4.5625 Q5.4375 4.9531 6.4062 5.2812 ZM5.2812 11.2812 Q5.3594 8.875 5.9219 6.7188 Q4.7188 6.3125 3.5938 5.7656 Q1.7656 8.1562 1.5156 11.2812 L5.2812 11.2812 ZM7.3594 7.125 Q6.7969 9.2031 6.7969 11.2812 L11.2812 11.2812 L11.2812 7.5938 Q9.2031 7.5938 7.3594 7.125 ZM12.7188 7.5938 L12.7188 11.2812 L17.2031 11.2812 Q17.2031 9.2031 16.6406 7.125 Q14.7969 7.5938 12.7188 7.5938 ZM6.7969 12.7188 Q6.875 14.9531 7.3594 16.875 Q9.2812 16.4062 11.2812 16.4062 L11.2812 12.7188 L6.7969 12.7188 ZM12.7188 12.7188 L12.7188 16.4062 Q14.7969 16.4062 16.6406 16.875 Q17.125 14.9531 17.2031 12.7188 L12.7188 12.7188 ZM7.8438 18.3125 Q8.0781 19.0469 8.4062 19.5938 Q9.6875 21.9219 11.2812 22.4062 L11.2812 17.8438 Q9.4375 17.9219 7.8438 18.3125 ZM8 21.6875 Q7.5156 21.0469 7.1094 20.2812 Q6.7188 19.5156 6.4062 18.7188 Q5.4375 18.9531 4.5625 19.4375 Q6.0781 20.875 8 21.6875 ZM5.9219 17.2812 Q5.3594 15.0469 5.2812 12.7188 L1.5156 12.7188 Q1.7656 15.8438 3.5938 18.2344 Q4.6406 17.6875 5.9219 17.2812 ZM16 21.6875 Q17.9219 20.875 19.4375 19.4375 Q18.5625 18.9531 17.5938 18.7188 Q17.2812 19.5156 16.875 20.2812 Q16.4844 21.0469 16 21.6875 ZM12.7188 17.8438 L12.7188 22.4062 Q14.3125 21.9219 15.5938 19.5938 Q15.9219 18.9531 16.1562 18.3125 Q14.4844 17.9219 12.7188 17.8438 ZM18.0781 17.2812 Q19.3594 17.6875 20.4062 18.2344 Q22.2344 15.8438 22.4844 12.7188 L18.7188 12.7188 Q18.6406 15.0469 18.0781 17.2812 ZM22.4844 11.2812 Q22.2344 8.1562 20.4062 5.7656 Q19.3594 6.3125 18.0781 6.7188 Q18.6406 8.875 18.7188 11.2812 L22.4844 11.2812 ZM16.875 3.6875 Q17.2812 4.4844 17.5938 5.2812 Q18.5625 5.0469 19.4375 4.5625 Q17.9219 3.125 16 2.3125 Q16.4844 2.9531 16.875 3.6875 ZM16.1562 5.6875 Q15.9219 5.0469 15.5938 4.4062 Q14.3125 2.0781 12.7188 1.5938 L12.7188 6.1562 Q14.5625 6.0781 16.1562 5.6875 Z" } -.phone-number-field > .left-pane > .combo-box .list-view .country-cell { +.phone-number-field > .left-pane > .left-box > .combo-box .list-view .country-cell { -fx-padding: 5px; } -.phone-number-field > .left-pane > .combo-box .list-view .country-cell.preferred { +.phone-number-field > .left-pane > .left-box > .combo-box .list-view .country-cell.preferred { -fx-font-weight: bold; } -.phone-number-field > .left-pane > .combo-box .list-view .country-cell .flag-wrapper { +.phone-number-field > .left-pane > .left-box > .combo-box .list-view .country-cell .flag-wrapper { -fx-padding: 1px; -fx-background-color: -fx-selection-bar-text; } \ No newline at end of file