/*
* Copyright 2014 michael-simons.eu.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ac.simons.bikingFX;
import ac.simons.bikingFX.api.JsonRetrievalTask;
import ac.simons.bikingFX.bikes.MilageChangeListener;
import ac.simons.bikingFX.bikes.Bike;
import ac.simons.bikingFX.bikingPictures.BikingPicture;
import ac.simons.bikingFX.bikingPictures.FlipImageService;
import ac.simons.bikingFX.gallery.GalleryPicture;
import ac.simons.bikingFX.gallery.GalleryPictureTableCell;
import ac.simons.bikingFX.common.ColorTableCell;
import ac.simons.bikingFX.common.LocalDateTableCell;
import ac.simons.bikingFX.tracks.Track;
import ac.simons.bikingFX.tracks.Track.Type;
import java.net.URL;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.prefs.BackingStoreException;
import java.util.prefs.Preferences;
import java.util.stream.Collectors;
import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener.Change;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.concurrent.Worker.State;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.chart.PieChart;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonBar.ButtonData;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Control;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import javafx.util.converter.IntegerStringConverter;
import static java.lang.Math.round;
import static java.lang.String.format;
import static javafx.beans.binding.Bindings.createStringBinding;
/**
* @author Michael J. Simons, 2014-10-07
*/
public class RootController implements Initializable {
private static final Logger logger = Logger.getLogger(RootController.class.getName());
public static class LoadedImageFilter implements Predicate<Node> {
@Override
public boolean test(Node node) {
return node instanceof StackPane && ((StackPane)node).getChildren().get(0).getUserData() != null;
}
}
@FunctionalInterface
public interface PasswordSupplier {
public String getPassword(boolean refresh);
}
/** Currently used application bundle */
private ResourceBundle resources;
@FXML
private HBox bikingPicturesContainer;
@FXML
private TableView<Bike> viewBikes;
@FXML
private TableColumn<Bike, String> viewBikeName;
@FXML
private TableColumn<Bike, Color> viewBikeColor;
@FXML
private TableColumn<Bike, LocalDate> viewBikeBoughtOn;
@FXML
private TableColumn<Bike, LocalDate> viewBikeDecommissionedOn;
@FXML
private TableColumn<Bike, Integer> viewBikeMilage;
@FXML
private PieChart chartMilagePerBike;
@FXML
private TableView<GalleryPicture> viewGalleryPictures;
@FXML
private TableColumn<GalleryPicture, LocalDate> viewGalleryPictureTakenOn;
@FXML
private TableColumn<GalleryPicture, String> viewGalleryPictureDescription;
@FXML
private TableColumn<GalleryPicture, Integer> viewGalleryPictureImage;
@FXML
private TableView<Track> viewTracks;
@FXML
private TableColumn<Track, LocalDate> viewTrackCoveredOn;
@FXML
private TableColumn<Track, String> viewTrackName;
@FXML
private WebView viewTrackMap;
private ObservableList<BikingPicture> bikingPictures;
private final Random random = new Random(System.currentTimeMillis());
/**
* Used to store the password for biking.michael-simons.eu. The password is actually
* stored in plain text. I propably wouldn't recommend that in a more serious
* application.
*/
private final Preferences preferences = Preferences.userNodeForPackage(this.getClass());
private FlipImageService flipImageService;
/**
* Listens for changes on the {@link Bike#milageProperty() } and transfers
* them to the server side api
*/
private MilageChangeListener milageChangeListener;
@Override
public void initialize(URL location, ResourceBundle resources) {
this.resources = resources;
bikingPictures = JsonRetrievalTask.get(BikingPicture::new, "/bikingPictures.json");
// Start loading image views when pictures are available
bikingPictures.addListener((Change<? extends BikingPicture> change) -> {
if (!change.getList().isEmpty()) {
loadPictures();
}
});
// Load more images when size changes
bikingPicturesContainer.widthProperty().addListener((observable, oldValue, newValue) -> {
loadPictures();
});
// Prepare flipservice, depends on container so don't initialise in constructor
this.flipImageService = new FlipImageService(this.bikingPictures, this.bikingPicturesContainer, this.random);
// Prepare listener for milage property
this.milageChangeListener = new MilageChangeListener(this::retrievePassword, this::storePassword, this.resources);
// Display a simple popup when adding milage fails
this.milageChangeListener.setOnFailed(state -> {
final Alert alert = new Alert(AlertType.ERROR);
alert.setTitle(resources.getString("common.error"));
alert.setHeaderText(null);
alert.setContentText(state.getSource().getException().getMessage());
alert.showAndWait();
});
// Prepare bike graph
chartMilagePerBike.setData(FXCollections.observableArrayList());
chartMilagePerBike.setLegendVisible(false);
// Get all bikes
final ObservableList<Bike> bikes = JsonRetrievalTask.get(Bike::new, "/bikes.json?all=true");
// Configure milage controller for each bike
bikes.addListener(this::watchChangesToBikeList);
viewBikes.setItems(bikes);
viewBikeName.setCellValueFactory(new PropertyValueFactory<>("name"));
viewBikeColor.setCellValueFactory(new PropertyValueFactory<>("color"));
viewBikeColor.setCellFactory(ColorTableCell::new);
viewBikeBoughtOn.setCellValueFactory(new PropertyValueFactory<>("boughtOn"));
viewBikeBoughtOn.setCellFactory(LocalDateTableCell::new);
viewBikeDecommissionedOn.setCellValueFactory(new PropertyValueFactory<>("decommissionedOn"));
viewBikeDecommissionedOn.setCellFactory(LocalDateTableCell::new);
viewBikeMilage.setCellValueFactory(new PropertyValueFactory<>("milage"));
viewBikeMilage.setCellFactory(TextFieldTableCell.forTableColumn(new IntegerStringConverter()));
viewGalleryPictures.setItems(JsonRetrievalTask.get(GalleryPicture::new, "/galleryPictures.json"));
viewGalleryPictureTakenOn.setCellValueFactory(new PropertyValueFactory<>("takenOn"));
viewGalleryPictureTakenOn.setCellFactory(LocalDateTableCell::new);
viewGalleryPictureDescription.setCellValueFactory(new PropertyValueFactory<>("description"));
viewGalleryPictureDescription.setCellFactory((TableColumn<GalleryPicture, String> column) -> {
final TableCell<GalleryPicture, String> cell = new TableCell<>();
final Text text = new Text();
cell.setGraphic(text);
cell.setPrefHeight(Control.USE_COMPUTED_SIZE);
// Bind wrapping width of the text to the actual width of the cell
text.wrappingWidthProperty().bind(cell.widthProperty());
// Update text with content from cell
text.textProperty().bind(cell.itemProperty());
return cell;
});
// Bind prefered width of column to width of table minus the first column to fill up the remaining space.
viewGalleryPictureDescription.prefWidthProperty().bind(
viewGalleryPictures.widthProperty()
.subtract(viewGalleryPictureTakenOn.prefWidthProperty())
.subtract(viewGalleryPictureImage.widthProperty())
);
viewGalleryPictureImage.setCellValueFactory(new PropertyValueFactory<>("id"));
viewGalleryPictureImage.setCellFactory(GalleryPictureTableCell::new);
// Fill the remainig space of the table
viewGalleryPictureImage.prefWidthProperty().bind(
viewGalleryPictures.widthProperty()
.subtract(viewGalleryPictureTakenOn.widthProperty())
.subtract(viewGalleryPictureDescription.widthProperty())
);
// This is necessary because the filtered list is immutable and therefor
// not sortable, so we wrap it.
final SortedList<Track> tracks = new SortedList<>(JsonRetrievalTask.get(Track::new, "/tracks.json").filtered(track -> track.getType() == Type.biking));
viewTracks.setItems(tracks);
tracks.comparatorProperty().bind(viewTracks.comparatorProperty());
viewTrackCoveredOn.setCellFactory(LocalDateTableCell::new);
viewTrackCoveredOn.setCellValueFactory(new PropertyValueFactory<>("coveredOn"));
viewTrackName.setCellValueFactory(new PropertyValueFactory<>("name"));
viewTrackName.prefWidthProperty().bind(viewTracks.widthProperty().subtract(viewTrackCoveredOn.widthProperty()));
// Establish default sort order
viewTracks.getSortOrder().add(viewTrackCoveredOn);
// HTML view of selected track
viewTrackMap.setContextMenuEnabled(false);
final WebEngine webEngine = viewTrackMap.getEngine();
webEngine.setJavaScriptEnabled(true);
// Watch loading of map data
webEngine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> {
if (newState == State.SUCCEEDED) {
logger.log(Level.FINE, "Loading done, now preparing map.");
// enable javascript and set every relevant width and height to 100%
// so that the webview is automatically filled
webEngine.executeScript("$('html').height('100%'); $('body').height('100%'); $('#map').css('width', '100%').css('height', '100%'); ");
}
});
viewTracks.getSelectionModel().selectedItemProperty().addListener((observable, oldTrack, newTrack) -> {
if(newTrack != null) {
final String mapUrl = String.format("%s/tracks/%s/embed?width=%d&height=%d",
JsonRetrievalTask.HOST_AND_PORT,
newTrack.getId(),
viewTrackMap.widthProperty().intValue(),
viewTrackMap.heightProperty().intValue()
);
logger.log(Level.FINE, "Loading embedded map {0}", new Object[]{mapUrl});
webEngine.load(mapUrl);
}
});
}
public String retrievePassword() {
String password = this.preferences.get("password", "");
if(password.isEmpty()) {
final Dialog<String> passwordDialog = new Dialog<>();
passwordDialog.setTitle(resources.getString("enterPasswordDialog.title"));
passwordDialog.setHeaderText(null);
final DialogPane passwordDialogPane = passwordDialog.getDialogPane();
final PasswordField passwordField = new PasswordField();
passwordField.setPromptText(resources.getString("enterPasswordDialog.passwordFieldPrompt"));
// Create and add new button type for confirmation
final ButtonType confirmButtonType = new ButtonType(resources.getString("enterPasswordDialog.title"), ButtonData.OK_DONE);
passwordDialogPane.getButtonTypes().add(confirmButtonType);
// Retrieve node
final Node confirmButton = passwordDialogPane.lookupButton(confirmButtonType);
confirmButton.disableProperty().bind(passwordField.textProperty().isEmpty());
// Result converter
passwordDialog.setResultConverter(dialogButton -> {
final ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonData.OK_DONE ? passwordField.getText() : null;
});
// Create content
final GridPane grid = new GridPane();
grid.setHgap(10);
grid.setMaxWidth(Double.MAX_VALUE);
grid.setAlignment(Pos.CENTER_LEFT);
// Do layout
passwordField.setMaxWidth(Double.MAX_VALUE);
GridPane.setHgrow(passwordField, Priority.ALWAYS);
GridPane.setFillWidth(passwordField, true);
grid.add(new Label(passwordField.getPromptText()), 0, 0);
grid.add(passwordField, 1, 0);
passwordDialogPane.setContent(grid);
Platform.runLater(() -> passwordField.requestFocus());
password = passwordDialog.showAndWait().orElse(null);
}
return password;
}
public void storePassword(final Optional<String> password) {
try {
if(!password.isPresent() || password.get().isEmpty()) {
this.preferences.remove("password");
} else {
this.preferences.put("password", password.get());
}
this.preferences.flush();
} catch (BackingStoreException ex) {
logger.log(Level.WARNING, "Had some problems storing a password", ex);
}
}
/**
* Observes changes to the list of all bikes. If bikes are added, add the milage
* listener to the property and also add a data element to the graph
* @param change
*/
final void watchChangesToBikeList(final Change<? extends Bike> change) {
while (change.next()) {
if (!change.wasAdded()) {
continue;
}
final List<? extends Bike> addedSublist = change.getAddedSubList();
// Add milage listener
addedSublist.stream()
.map(bike -> bike.milageProperty())
.forEach(milage -> milage.addListener(milageChangeListener));
// Add data elements to chart
this.chartMilagePerBike.getData().addAll(
addedSublist.stream()
.map(this::createDataElementForBike)
.collect(Collectors.toList())
);
}
}
/**
* Creates a PieChart.Data element for the given bike. The data elements
* value is bound to the milageProperty of the bike. The node of the element
* then is watched and if it becomes available, it's style propert is bound
* to the color of the bike
*
* @param bike Bike whos milage is displayed in the pie chart
* @return a new pie chart data element
*/
final PieChart.Data createDataElementForBike(final Bike bike) {
final Property<Integer> milageProperty = bike.milageProperty();
final PieChart.Data data = new PieChart.Data(bike.getName(), 0);
data.pieValueProperty().bind(milageProperty);
// When the data is attached, the node becomes availabe
data.nodeProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
return;
}
// and is styleable
newValue.styleProperty().bind(
// create a binding to a String format containing the rgb representation of the color of the bike
createStringBinding(() -> {
final Color c = bike.getColor();
return format("-fx-pie-color: rgb(%d, %d, %d);", round(c.getRed() * 255.0), round(c.getGreen() * 255.0), round(c.getBlue() * 255.0));
},
bike.colorProperty())
);
});
return data;
}
final void loadPictures() {
final ObservableList<Node> children = bikingPicturesContainer.getChildren();
final int numberOfNeededElements = (int) Math.ceil(bikingPicturesContainer.getWidth() / 150.0) - children.size();
if(bikingPictures.isEmpty() || numberOfNeededElements <= 0) {
return;
}
// Get currently loaded images
final Set<BikingPicture> loadedBikingPictures = children.stream()
.filter(new LoadedImageFilter())
.map(node -> (BikingPicture)((StackPane)node).getChildren().get(0).getUserData())
.collect(Collectors.toSet());
final List<BikingPicture> available = bikingPictures.filtered(bikingPicture -> !loadedBikingPictures.contains(bikingPicture));
int i = 0, remainingNumberOfPictures = available.size();
int neededViewsLeft = numberOfNeededElements;
while (neededViewsLeft > 0 && remainingNumberOfPictures > 0) {
int rand = random.nextInt(remainingNumberOfPictures);
if (rand < neededViewsLeft) {
final ProgressIndicator progressIndicator = new ProgressIndicator();
final HBox box = new HBox(progressIndicator);
box.setFillHeight(false);
box.setAlignment(Pos.CENTER);
box.setMinHeight(113);
box.setMaxHeight(113);
box.setMinWidth(150);
box.setMaxWidth(150);
final StackPane stackPane = new StackPane(box);
final BikingPicture bikingPicture = available.get(i);
final Image image = new Image(bikingPicture.getSrc(), 150, 113, true, true, true);
progressIndicator.progressProperty().bind(image.progressProperty());
image.progressProperty().addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() == 1) {
final ImageView imageView = new ImageView(image);
imageView.setUserData(bikingPicture);
stackPane.getChildren().set(0, imageView);
}
});
children.add(stackPane);
neededViewsLeft--;
}
remainingNumberOfPictures--;
i++;
}
if(flipImageService != null && !flipImageService.isRunning()) {
flipImageService.start();
}
}
}