[ Exercises | Chapter Index | Main Index ]

Solution for Programming Exercise 13.4


This page contains a sample solution to one of the exercises from Introduction to Programming Using Java.


Exercise 13.4:

The sample program PhoneDirectoryFileDemo.java from Subsection 11.3.2 keeps data for a "phone directory" in a file in the user's home directory. Exercise 11.5 asked you to revise that program to use an XML format for the data. Both programs have a simple command-line user interface. For this exercise, you should provide a GUI interface for the phone directory data. You can base your program either on the original sample program or on the modified XML version from the exercise. Use a TableView to hold the data. The user should be able to edit all the entries in the table. Also, the user should be able to add and delete rows. Include either buttons or menu commands that can be used to perform these actions. The delete command should delete the selected row, if any. New rows should be added at the end of the table.

Your program should load data from the file when it starts and save data to the file when it ends, just as the two previous programs do. For a GUI program, you need to save the data when the user closes the window, which ends the program. To do that, you can add a listener to the program's Stage to handle the WindowHidden event. For an example of using that event, the Mandelbrot Viewer program from Section 13.5 uses it to save preferences when the program ends. For an example of creating an editable table, see ScatterPlotTableDemo.java.

(I suggest keeping things simple. You not being asked to write a real phone book application! The point is mostly to make an editable table. My program has text input boxes for name and number, and an "Add" button for adding a new entry containing the input in those boxes. My program always saves the data, whether or not the user has changed it. The interface will be poor: The user has to double-click a cell to edit it and press return to finish the edit and save the new value. It is possible to make a table with a better editing interface, but to do that, you need to write a new CellFactory class for the table.)


Discussion

To make a table with two columns holding names and phone numbers, we need a class to represent a row in the table. For an editable table, the individual data values in the class should be stored in observable properties. I wrote a simple nested class, PhoneEntry, for that purpose, similar to the Point class that was used to represent rows in the ScatterPlotTableDemo program:

/**
 * A class that represents one phone book entry, with
 * observable properties nameProperty() and numberProperty()
 * to hold the data for the entry. (Note that the table must
 * be public and the nameProperty() and numberProperty() method
 * must be public for this class to work with an editable table.)
 */
public static class PhoneEntry {
    StringProperty name;
    StringProperty number;
    PhoneEntry(String name, String number) {
        this.name = new SimpleStringProperty(name);
        this.number = new SimpleStringProperty(number);
    }
    public StringProperty nameProperty() {
        return name;
    }
    public StringProperty numberProperty() {
        return number;
    }
}

The table can then be created as a variable of type TableView<PhoneEntry>. The data for the phone book will be stored in the list of items in the table, which is of type ObservableList<PhoneEntry>. The program use a variable, phoneEntries, to refer to that list:

ObservableList<PhoneEntry> phoneEntries = phoneBook.getItems();

The two table columns have to be created and configured. Again, this is similar to what was done in ScatterPlotTableDemo, except that in this program, the user is allowed to sort and resize the columns And since the values in the cells are Strings rather than Doubles, no StringConverter is needed for the cell factory. The "Name" column is created as follows:

TableColumn<PhoneEntry, String> nameColumn = new TableColumn<>("Name");
nameColumn.setCellValueFactory( new PropertyValueFactory<PhoneEntry, String>("name") );
nameColumn.setCellFactory( TextFieldTableCell.forTableColumn() );
nameColumn.setPrefWidth(200);  // (Default size is too small)
phoneBook.getColumns().add(nameColumn);
nameColumn.setEditable(true);

The program has "Add" and "Delete" buttons for adding and deleting phone entries. (This part is actually modeled on EditListDemo.java rather than ScatterPlotListDemo.) At first, I tried just adding an empty row when the user clicks "Add" and leaving it up to the user to fill in the name and number in that row, but I found that it was hard even to see the empty row in the table. So instead, I added text input boxes for the new name and number, and the "Add" button adds a new entry containing the data from those input boxes. This also allowed me to check that the two input boxes were both non-empty before adding the entry to the table, so that I could avoid having rows in the table where the name or number was empty.

For the file that stores the phone book data, I used the simple data format from the original program. That program already had code for loading and saving the data, at the beginning and end of its main() routine. I copied that code into two instance methods named loadPhoneBook() and savePhoneBook(), and I modified the code to use data stored in an ObservableList instead of in a Map. The savePhoneBook() method is called by an event handler when the window is closed, as suggested in the exercise:

stage.setOnHidden( e -> savePhoneBook(dataFile, phoneEntries) );

The Solution


import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.TextField;
import javafx.scene.control.TableView;
import javafx.scene.control.TableColumn;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.geometry.Insets;
import javafx.beans.property.StringProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.ObservableList;

import java.io.File;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.Scanner;
import java.util.List;


/**
 * Lets the user edit a list names and phone numbers in a TableView.
 * The list is read from the file named ".phone_book_demo" in the
 * user's home directory, and the possibly changed data is written
 * back to that file when the program ends.  The file uses a
 * simple format: each line contains a name and a phone number,
 * separated by "%".  (The format can't correctly handle names that 
 * contain a '%' character, which should not be too likely.)
 */
public class PhoneDirectoryGUI extends Application {

    public static void main(String[] args) {
        launch();    
    }
    // -------------------------------------------------------------------------
    
    /**
     * The name of the file in which the phone book data is kept.  The
     * file is stored in the user's home directory.  The "." at the
     * beginning of the file name means that the file will be a
     * "hidden" file on Unix-based computers, including Linux and
     * Mac OS X.
     */
    private static String DATA_FILE_NAME = ".phone_book_demo";


    /**
     * A class that represents one represents one phone book entry,
     * with observable properties nameProperty() and numberProperty()
     * to hold the data for the entry. (Note that the table must
     * be public and the nameProperty() and numberProperty() method
     * must be public for this class to work with an editable table.)
     */
    public static class PhoneEntry {
        StringProperty name;
        StringProperty number;
        PhoneEntry(String name, String number) {
            this.name = new SimpleStringProperty(name);
            this.number = new SimpleStringProperty(number);
        }
        public StringProperty nameProperty() {
            return name;
        }
        public StringProperty numberProperty() {
            return number;
        }
    }
    
    
    /**
     * start() method creates the table, reads the existing data file if
     * there is one, and sets up the GUI.  It installs a WindowHidden
     * event handler on the stage that will save the data when the window
     * is closed.
     */
    public void start(Stage stage) {
        
        /* Create a dataFile variable of type File to represent the
         * data file that is stored in the user's home directory. */

        File userHomeDirectory = new File( System.getProperty("user.home") );
        File dataFile = new File( userHomeDirectory, DATA_FILE_NAME );

        /* Create a TableView to hold the data for the phonebook, and
         * load the existing phonebook data from the file into the table. 
         * The loadPhoneBook method will not return if an error occurs. */

        TableView<PhoneEntry> phoneBook = new TableView<>();
        phoneBook.setEditable(true);
        phoneBook.setPrefSize(420,350);
        ObservableList<PhoneEntry> phoneEntries = phoneBook.getItems();
        
        loadPhoneBook(dataFile, phoneEntries);
        if (phoneEntries.size() == 0)
            phoneEntries.add( new PhoneEntry("name","number"));
        
        /* Define the table columns for the "Name" and "Phone Number" columns. */
        
        TableColumn<PhoneEntry, String> nameColumn = new TableColumn<>("Name");
        nameColumn.setCellValueFactory( new PropertyValueFactory<PhoneEntry, String>("name") );
        nameColumn.setCellFactory( TextFieldTableCell.forTableColumn() );
        nameColumn.setPrefWidth(200);  // (Default size is too small)
        phoneBook.getColumns().add(nameColumn);
        nameColumn.setEditable(true);
        
        TableColumn<PhoneEntry, String> numberColumn = new TableColumn<>("Phone Number");
        numberColumn.setCellValueFactory( new PropertyValueFactory<PhoneEntry, String>("number") );
        numberColumn.setCellFactory( TextFieldTableCell.forTableColumn() );
        numberColumn.setPrefWidth(200);  // (Default size is too small)
        phoneBook.getColumns().add(numberColumn);

        /* Finish making the GUI, with "Add" and "Delete" buttons placed into
         * the window below the table. */
        
        TextField nameInput = new TextField();
        nameInput.setPrefColumnCount(10);
        nameInput.setPromptText("(name)");
        TextField numberInput = new TextField();
        numberInput.setPromptText("(number)");
        numberInput.setPrefColumnCount(10);

        Button deleteButton = new Button("Delete Selected Entry");
        deleteButton.setOnAction( e -> {
            int selected = phoneBook.getSelectionModel().getSelectedIndex();
            if (selected >= 0)
                phoneEntries.remove( phoneBook.getSelectionModel().getSelectedIndex());
        });
        deleteButton.setMaxWidth(Double.POSITIVE_INFINITY);
        deleteButton.disableProperty().bind(
                phoneBook.getSelectionModel().selectedIndexProperty().isEqualTo(-1));
        Button addButton = new Button("Add:");
        addButton.setOnAction( e -> {  // add a new row to the table
            String name = nameInput.getText().trim();
            String number = numberInput.getText().trim();
            if (name.length() == 0 ) {
                    // Don't allow empty name in the table
                error("You must enter a name and number before adding an entry.");
                nameInput.requestFocus();
                return;
            }
            if (number.length() == 0 ) {
                    // Don't allow an empty phone number in the table
                error("You must enter a name and number before adding an entry.");
                numberInput.requestFocus();
                return;
            }
            nameInput.setText("");   // empty the input boxes, since the data has been
            numberInput.setText(""); //        copied into the table
            PhoneEntry newEntry = new PhoneEntry(name,number);
            phoneEntries.add( newEntry );
            phoneBook.scrollTo(phoneEntries.size() - 1); // make sure new row is visib.e
            phoneBook.getSelectionModel().select(phoneEntries.size() - 1); // highlight new entry
        });
        HBox add = new HBox(8,addButton,nameInput,numberInput);
        add.setPadding( new Insets(5) );
        VBox buttons = new VBox(add,deleteButton);
        buttons.setStyle("-fx-border-color:black; -fx-border-width: 2px");
        
        BorderPane tableHolder = new BorderPane(phoneBook);
        tableHolder.setBottom(buttons);
        
        stage.setOnHidden( e -> savePhoneBook(dataFile, phoneEntries) );
        
        stage.setScene( new Scene(tableHolder) );
        stage.setTitle("Phone Book Editor");
        stage.show();
        
    } // end start();
    
    
    /**
     * If the data file already exists, load the entries from the data
     * file in the list (which is the items list from the TableView).  
     * The format of the file must be as follows:  Each line of the file 
     * represents one directory entry, with the name and the number for that 
     * entry separated by the character '%'.  If a file exists but does not
     * have this format, then the program terminates; this is done to
     * avoid overwriting a file that is being used for another purpose.
     */
    private void loadPhoneBook(File dataFile, List<PhoneEntry> entries) {
        if ( ! dataFile.exists() ) {
            message("No phone book data file found.  A new one\n"
                        + "will be created when the program ends,\n"
                        + "if you add any entries to the table.\n"
                        + "File name:\n    " + dataFile.getAbsolutePath());
        }
        else {
            try( Scanner scanner = new Scanner(dataFile) ) {
                while (scanner.hasNextLine()) {
                    String phoneEntry = scanner.nextLine();
                    int separatorPosition = phoneEntry.indexOf('%');
                    if (separatorPosition == -1)
                        throw new IOException("File is not a phonebook data file.");
                    String name = phoneEntry.substring(0, separatorPosition);
                    String number = phoneEntry.substring(separatorPosition+1);
                    entries.add( new PhoneEntry(name,number) );
                }
            }
            catch (IOException e) {
                error("Error in phone book data file.\n"
                            + "This program cannot continue.\n"
                            + "Data File name: \n   " + dataFile.getAbsolutePath());
                System.exit(1);
            }
        }
    }
    
    
    /**
     * Write the phone book entries from the list to the data files.
     * But if the list is empty, don't write anything.
     */
    private void savePhoneBook(File dataFile, List<PhoneEntry> entries) {
        if (entries.size() == 0)
            return;
        System.out.println("Saving phone directory to file " + 
                dataFile.getAbsolutePath() + " ...");
        PrintWriter out;
        try {
            out = new PrintWriter( dataFile );
        }
        catch (IOException e) {
            error("ERROR: Can't open data file for output."
                    + "Phone book data cannot be saved.");
            return;
        }
        for ( PhoneEntry entry : entries ) {
            out.println( entry.nameProperty().get() + "%" + entry.numberProperty().get() );
        }
        out.flush();
        out.close();
        if (out.checkError())
            error("ERROR: Some error occurred while writing the data file."
                    + "Phone book data might not have been saved correctly.");
    }
    
    
    /**
     *  A utility method for showing an informational message to the user.
     */
    private void message(String text) {
        Alert alert = new Alert(Alert.AlertType.INFORMATION, text);
        alert.setHeaderText(null);
        alert.showAndWait();
    }
    
    /**
     *  A utility method for showing an erro message to the user.
     */
    private void error(String text) {
        Alert alert = new Alert(Alert.AlertType.ERROR, text);
        alert.setHeaderText(null);
        alert.showAndWait();
    }
    
    
} // end PhoneDirectoryGUI

[ Exercises | Chapter Index | Main Index ]