1 /*
   2  * Copyright (c) 2008, 2013, Oracle and/or its affiliates.
   3  * All rights reserved. Use is subject to license terms.
   4  *
   5  * This file is available and licensed under the following license:
   6  *
   7  * Redistribution and use in source and binary forms, with or without
   8  * modification, are permitted provided that the following conditions
   9  * are met:
  10  *
  11  *  - Redistributions of source code must retain the above copyright
  12  *    notice, this list of conditions and the following disclaimer.
  13  *  - Redistributions in binary form must reproduce the above copyright
  14  *    notice, this list of conditions and the following disclaimer in
  15  *    the documentation and/or other materials provided with the distribution.
  16  *  - Neither the name of Oracle Corporation nor the names of its
  17  *    contributors may be used to endorse or promote products derived
  18  *    from this software without specific prior written permission.
  19  *
  20  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
  21  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
  22  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
  23  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
  24  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
  25  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
  26  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  27  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  28  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  29  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  30  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  31  */
  32 package com.javafx.experiments.scheduleapp.pages;
  33 
  34 import java.text.DateFormat;
  35 import java.text.SimpleDateFormat;
  36 import java.util.ArrayList;
  37 import java.util.Calendar;
  38 import java.util.Iterator;
  39 import java.util.List;
  40 import java.util.regex.Pattern;
  41 import javafx.animation.Interpolator;
  42 import javafx.animation.KeyFrame;
  43 import javafx.animation.KeyValue;
  44 import javafx.animation.Timeline;
  45 import javafx.beans.InvalidationListener;
  46 import javafx.beans.Observable;
  47 import javafx.beans.binding.DoubleBinding;
  48 import javafx.beans.property.SimpleDoubleProperty;
  49 import javafx.beans.value.ChangeListener;
  50 import javafx.beans.value.ObservableValue;
  51 import javafx.collections.ObservableList;
  52 import javafx.event.ActionEvent;
  53 import javafx.event.Event;
  54 import javafx.event.EventHandler;
  55 import javafx.geometry.HPos;
  56 import javafx.geometry.Orientation;
  57 import javafx.geometry.VPos;
  58 import javafx.scene.Node;
  59 import javafx.scene.control.ContentDisplay;
  60 import javafx.scene.control.Label;
  61 import javafx.scene.control.ListCell;
  62 import javafx.scene.control.ListView;
  63 import javafx.scene.control.Skin;
  64 import javafx.scene.image.Image;
  65 import javafx.scene.image.ImageView;
  66 import javafx.scene.input.MouseEvent;
  67 import javafx.scene.layout.ColumnConstraints;
  68 import javafx.scene.layout.GridPane;
  69 import javafx.scene.layout.Priority;
  70 import javafx.scene.layout.Region;
  71 import javafx.scene.layout.VBox;
  72 import javafx.scene.paint.Color;
  73 import javafx.scene.shape.Rectangle;
  74 import javafx.scene.text.Text;
  75 import javafx.stage.Window;
  76 import javafx.util.Callback;
  77 import javafx.util.Duration;
  78 import com.javafx.experiments.scheduleapp.TouchClickedEventAvoider;
  79 import com.javafx.experiments.scheduleapp.Page;
  80 import com.javafx.experiments.scheduleapp.control.EventPopoverPage;
  81 import com.javafx.experiments.scheduleapp.control.Popover;
  82 import com.javafx.experiments.scheduleapp.control.ResizableWrappingText;
  83 import com.javafx.experiments.scheduleapp.control.SearchBox;
  84 import com.javafx.experiments.scheduleapp.data.DataService;
  85 import com.javafx.experiments.scheduleapp.model.Session;
  86 import com.javafx.experiments.scheduleapp.model.SessionTime;
  87 import com.javafx.experiments.scheduleapp.model.Speaker;
  88 import com.sun.javafx.scene.control.skin.FXVK;
  89 
  90 import static com.javafx.experiments.scheduleapp.ConferenceScheduleApp.*;
  91 import static com.javafx.experiments.scheduleapp.Theme.*;
  92 import java.awt.image.BufferedImage;
  93 import java.io.IOException;
  94 import java.net.URL;
  95 import java.util.HashMap;
  96 import java.util.Map;
  97 import java.util.logging.Level;
  98 import java.util.logging.Logger;
  99 import javafx.embed.swing.SwingFXUtils;
 100 import javafx.scene.image.WritableImage;
 101 import javax.imageio.ImageIO;
 102 
 103 /**
 104  * Page showing searchable list of all speakers at the conference
 105  */
 106 public class SpeakersPage extends Page implements ChangeListener<String> {
 107     private static DateFormat DATE_TIME_FORMAT = new SimpleDateFormat("hh:mma EEE");
 108     private static final Pattern HYPERLINK_PATTERN = Pattern.compile("http(s)?://\\S+");
 109     private static final int PIC_SIZE = 48;
 110     private static final int GAP = 6;
 111     private static final int IMG_GAP = 12;
 112     private static final int MIN_HEIGHT = GAP + PIC_SIZE + 10 + GAP;
 113     private static final int TEXT_LEFT = GAP+PIC_SIZE+IMG_GAP;
 114     
 115     private final SpeakerList speakersList = new SpeakerList();
 116     private final SearchBox searchBox = new SearchBox();
 117     private final Map<Speaker,Image> speakerImageCache = new HashMap<>();
 118     
 119     /** 
 120      * index of currently expanded cell. We need to keep tack of the cell that 
 121      * is expanded so that when you scroll away from it and its cell is reused 
 122      * then scroll back we can put the cell back in the expanded state.
 123      */
 124     private int expandedCellIndex = -1;
 125     /**
 126      * the currently expanded cell or null if no cell is expanded. This is used 
 127      * to make sure only once cell is expanded at a time. So when we expand a 
 128      * new cell we can collapse this one.
 129      */
 130     private SpeakerListCell expandedCell = null;
 131 
 132     private Popover popover;
 133 
 134     public SpeakersPage(Popover popover, DataService dataService) {
 135         super("Speakers", dataService);
 136         this.popover = popover;
 137         if (IS_BEAGLE) {
 138             new TouchClickedEventAvoider(speakersList);
 139         }
 140         speakersList.setItems(dataService.getSpeakers());
 141         getChildren().setAll(speakersList,searchBox);
 142         searchBox.textProperty().addListener(this);
 143         
 144         // HORRIFIC!! The problem is that on Beagle right now, we're not seeing the
 145         // virtual keyboard layer getting hidden. This is likely a bug in the window
 146         // hardware layer support. This code will just move the keyboard out of the way.
 147         searchBox.focusedProperty().addListener(new InvalidationListener() {
 148             @Override public void invalidated(Observable observable) {
 149                 Iterator<Window> itr = Window.impl_getWindows();
 150                 while (itr.hasNext()) {
 151                     Window win = itr.next();
 152                     Object obj = win.getScene().getRoot().lookup(".fxvk");
 153                     if (obj instanceof FXVK) {
 154                         FXVK keyboard = (FXVK) obj;
 155                         System.err.println("Found virtual keyboard");
 156                         if (searchBox.isFocused()) {
 157                             keyboard.setVisible(true);
 158                             keyboard.setTranslateX(0);
 159                         } else {
 160                             keyboard.setVisible(false);
 161                             keyboard.setTranslateX(2000);
 162                         }
 163                     }
 164                 }
 165             }
 166         });
 167     }
 168     
 169     @Override public void reset() {
 170         searchBox.setText("");
 171         speakersList.scrollTo(0);
 172     }
 173 
 174     @Override protected void layoutChildren() {
 175         final int w = (int)getWidth() - 24;
 176         final int h = (int)getHeight() - 24;
 177         searchBox.resize(w,30);
 178         searchBox.setLayoutX(12);
 179         searchBox.setLayoutY(12);
 180         speakersList.resize(w,h - 42);
 181         speakersList.setLayoutX(12);
 182         speakersList.setLayoutY(53);
 183     }
 184     
 185     /**
 186      * Handle text searching
 187      */
 188     @Override public void changed(ObservableValue<? extends String> ov, String oldValue, String newValue) {
 189         final ObservableList<Speaker> items = speakersList.getItems();
 190         long start = System.currentTimeMillis();
 191         if (newValue == null || newValue.length() == 0) {
 192             items.setAll(dataService.getSpeakers());
 193         } else {
 194             final List<Speaker> speakers = dataService.getSpeakers();
 195             final ArrayList<Speaker> results = new ArrayList(speakers.size());
 196             final char[] search = newValue.toLowerCase().toCharArray();
 197             for(int i=0; i < speakers.size(); i++) {
 198                 final Speaker s = speakers.get(i);
 199                 final String first = s.firstName;
 200                 boolean match = true;
 201                 final int max = first.length() < search.length ? first.length() : search.length;
 202                 for (int c=0; c < max; c++) {
 203                     if(Character.toLowerCase(first.charAt(c)) != search[c]) {
 204                         match = false;
 205                         break;
 206                     }
 207                 }
 208                 if (match == false) {
 209                     match = true;
 210                     final String last = s.lastName;
 211                     final int maxl = last.length() < search.length ? last.length() : search.length;
 212                     for (int c=0; c < maxl; c++) {
 213                         if(Character.toLowerCase(last.charAt(c)) != search[c]) {
 214                             match = false;
 215                             break;
 216                         }
 217                     }
 218                 }
 219                 if (match) results.add(s);
 220             }
 221             System.out.println("Setting data..."); 
 222             long t0 = System.currentTimeMillis(); 
 223             items.setAll(results);
 224             //ObservableList<Speaker> empty = FXCollections.observableArrayList(); 
 225             //speakersList.setItems(empty); 
 226             //items.clear(); 
 227             //speakersList.setItems(FXCollections.observableArrayList(results)); 
 228             long t1 = System.currentTimeMillis(); 
 229             System.out.println("Done: " + (t1 - t0)); 
 230 
 231         }
 232         long end = System.currentTimeMillis();
 233         System.out.println("search took = "+(end-start));
 234         // clear expanded cells
 235         expandedCellIndex = -1;
 236         expandedCell = null;
 237     }
 238     
 239     
 240     /**
 241      * Helper method used to update a session row in speakers extended info to 
 242      * show or not show a star for when it added to sessions to attend.
 243      * 
 244      * @param sessionTitle The label to add/remove star from
 245      * @param show true if the star should be added to the label, false if it 
 246      *             should be removed.
 247      */
 248     private static void updateStar(Label sessionTitle, boolean show) {
 249         if(show) {
 250             ImageView star = new ImageView(STAR);
 251             sessionTitle.setGraphic(star);
 252             sessionTitle.setContentDisplay(ContentDisplay.RIGHT);
 253         } else {
 254             sessionTitle.setGraphic(null);
 255         }
 256     }
 257     
 258     /**
 259      * Custom list for speakers, will all standard CSS removed an using our 
 260      * custom SpeakerListCell.
 261      */
 262     private class SpeakerList extends ListView<Speaker> implements Callback<ListView<Speaker>, ListCell<Speaker>>{
 263         public SpeakerList(){
 264             getStyleClass().setAll("twitter-list-view");
 265 //            skinClassNameProperty().set("com.sun.javafx.scene.control.skin.ListViewSkin");
 266             setSkin(new com.sun.javafx.scene.control.skin.ListViewSkin(this));
 267             setCellFactory(this);
 268             // hack workaround for cell sizing
 269             Node node = lookup(".clipped-container");
 270             if (node != null) node.setManaged(true);
 271         }
 272 
 273         @Override public ListCell<Speaker> call(ListView<Speaker> p) {
 274             return new SpeakerListCell();
 275         }
 276     }
 277 
 278     /**
 279      * The main body of the speaker list cell. This is separate from the cell 
 280      * so that it can be cached and not need to be updated while the cells clip 
 281      * is changing during expansion.
 282      */
 283     private final static class SpeakerListCellBody extends Region {
 284         private final Text name = new Text();
 285         private final Text company = new Text();
 286         private final ImageView image = new ImageView();
 287         private final ImageView shadow = new ImageView(SHADOW_PIC);
 288         private final Rectangle imageBorder = new Rectangle(PIC_SIZE+6, PIC_SIZE+6,Color.WHITE);
 289         private final Rectangle dividerLine = new Rectangle(1, 1);
 290 
 291         public SpeakerListCellBody() {
 292             name.setFont(LARGE_FONT);
 293             name.setFill(DARK_GREY);
 294             name.setTextOrigin(VPos.CENTER);
 295             company.setFont(LARGE_LIGHT_FONT);
 296             company.setFill(GRAY);
 297             company.setTextOrigin(VPos.CENTER);
 298             dividerLine.setFill(Color.web("#ddd"));
 299             dividerLine.setSmooth(false);
 300             shadow.setFitWidth(PIC_SIZE+6);
 301             image.setFitWidth(PIC_SIZE);
 302             image.setFitHeight(PIC_SIZE);
 303             getChildren().addAll(name,company,dividerLine,shadow,imageBorder,image);
 304         }
 305         
 306         @Override protected void layoutChildren() {
 307             final int w = (int)getWidth();
 308             final int h = (int)getHeight();
 309             final int centerY = (int)(MIN_HEIGHT/2d);
 310             dividerLine.setWidth(w);
 311             image.setLayoutX(GAP);
 312             image.setLayoutY(GAP+3);
 313             imageBorder.setLayoutX(GAP-3);
 314             imageBorder.setLayoutY(GAP);
 315             shadow.setLayoutX(GAP-3);
 316             shadow.setLayoutY(GAP+PIC_SIZE+6);
 317             name.setLayoutX(TEXT_LEFT);
 318             name.setLayoutY(centerY-13);
 319             company.setLayoutX(TEXT_LEFT);
 320             company.setLayoutY(centerY+13);
 321         }
 322     }
 323     
 324     /**
 325      * The extended info section of the speaker list cell. This is separate from
 326      * the cell so that it can be cached and not need to be updated while the 
 327      * cells clip is changing during expansion.
 328      */
 329     private final static class SpeakerListCellExtended extends GridPane {
 330         private final VBox sessionsList = new VBox();
 331         private final Text jobTitleText = new Text("TITLE");
 332         private final ResizableWrappingText jobTitle = new ResizableWrappingText();
 333         private final Text bioText = new Text("BIO");
 334         private final ResizableWrappingText bio = new ResizableWrappingText();
 335 
 336         public SpeakerListCellExtended() {
 337             // create extended content
 338             setVgap(12);
 339             getColumnConstraints().setAll(new ColumnConstraints(TEXT_LEFT-GAP));
 340             jobTitleText.setFont(SMALL_FONT);
 341             jobTitleText.setFill(BLUE);
 342             GridPane.setConstraints(jobTitleText, 0, 0,1,1, HPos.LEFT, VPos.TOP);
 343             jobTitle.setFont(LIGHT_FONT);
 344             jobTitle.setFill(DARK_GREY);
 345             GridPane.setConstraints(jobTitle, 1, 0);
 346             bioText.setFont(SMALL_FONT);
 347             bioText.setFill(BLUE);
 348             GridPane.setConstraints(bioText, 0, 1,1,1, HPos.LEFT, VPos.TOP);
 349             bio.setFont(LIGHT_FONT);
 350             bio.setFill(DARK_GREY);
 351             GridPane.setConstraints(bio, 1, 1);
 352             Text sessionsText = new Text("SESSIONS");
 353             sessionsText.setFont(SMALL_FONT);
 354             sessionsText.setFill(BLUE);
 355             GridPane.setConstraints(sessionsText, 0, 2,1,1, HPos.LEFT, VPos.TOP);
 356             sessionsList.getStyleClass().setAll("speaker-session-list");
 357             sessionsList.setFillWidth(true);
 358             GridPane.setConstraints(sessionsList, 1, 2,1,1, HPos.LEFT, VPos.TOP, Priority.ALWAYS, Priority.NEVER);
 359             
 360             getChildren().addAll(jobTitleText, jobTitle, bioText, bio, sessionsText,sessionsList);
 361         }
 362     }
 363     
 364     /**
 365      * Custom list cell for the speakers list. It uses a pattern to avoid 
 366      * standard list cell skin to have minimal overhead.
 367      */
 368     private class SpeakerListCell extends ListCell<Speaker> implements Skin<SpeakerListCell>, EventHandler {
 369         private final SpeakerListCellBody body = new SpeakerListCellBody();
 370         private final SpeakerListCellExtended expandedContent = new SpeakerListCellExtended();
 371         private final ImageView arrow = new ImageView(RIGHT_ARROW);
 372         private int cellIndex;
 373         private final Rectangle clip = new Rectangle();
 374         private final SimpleDoubleProperty expansion = new SimpleDoubleProperty(0) {
 375             @Override protected void invalidated() {
 376                 super.invalidated();
 377                 requestLayout();
 378             }
 379         };
 380         
 381         private SpeakerListCell() {
 382             super();
 383             // we don't need any of the labeled functionality of the default cell skin, so we replace skin with our own
 384             // in this case using this same class as it saves memory. This skin is very simple its just a HBox container
 385             setSkin(this);
 386             getStyleClass().clear();
 387             arrow.rotateProperty().bind(new DoubleBinding() {
 388                 { bind(expansion); }
 389                 @Override protected double computeValue() {
 390                     return 90 - (180*expansion.get());
 391                 }
 392             });
 393             clip.setSmooth(false);
 394             setClip(clip);
 395             getChildren().addAll(arrow);
 396             setOnMouseClicked(this);
 397             setPickOnBounds(true);
 398         }
 399 
 400         @Override public Orientation getContentBias() {
 401             return Orientation.HORIZONTAL;
 402         }
 403 
 404         @Override public void resize(double width, double height) {
 405             super.resize(width, height);
 406             clip.setWidth(width);
 407             clip.setHeight(height);
 408         }
 409         
 410         private Timeline expansionTimeline = null;
 411         
 412         @Override public void handle(Event t) {
 413             final double e = this.expansion.get();
 414             expandCollapse(this.expansion.get() < 1);
 415         }
 416         
 417         private void expandCollapse(boolean expand) {
 418             if (expansionTimeline != null) expansionTimeline.stop();
 419             if (expand) {
 420                 if (expandedCell != null) expandedCell.expandCollapse(false);
 421                 expandedCellIndex = getIndex();
 422                 expandedCell = this;
 423                 expansionTimeline = new Timeline(
 424                     new KeyFrame(Duration.millis(200), new KeyValue(expansion, 1, Interpolator.EASE_BOTH))
 425                 );
 426                 expansionTimeline.play();
 427             } else {
 428                 expansionTimeline = new Timeline(
 429                     new KeyFrame(Duration.millis(200), new KeyValue(expansion, 0, Interpolator.EASE_BOTH))
 430                 );
 431                 expansionTimeline.setOnFinished(new EventHandler<ActionEvent>() {
 432                     @Override public void handle(ActionEvent t) {
 433                         if (getIndex() == expandedCellIndex) {
 434                             expandedCellIndex = -1;
 435                             expandedCell = null;
 436                         }
 437                     }
 438                 });
 439                 expansionTimeline.play();
 440             }
 441         }
 442 
 443         @Override protected double computePrefWidth(double height) {
 444             return 100;
 445         }
 446 
 447         @Override protected double computePrefHeight(double width) {
 448             width = speakersList.getWidth();
 449             double headerHeight = MIN_HEIGHT;
 450             final double e = this.expansion.get();
 451             if (e == 0) {
 452                 return (int) (headerHeight + 0.5d);
 453             } else {
 454                 final int textLeft = GAP+PIC_SIZE+IMG_GAP;
 455                 final double expandedContentHeight = expandedContent.prefHeight(width - textLeft - GAP);
 456                 return (int) (headerHeight + (expandedContentHeight * e) + GAP + 0.5d);
 457             }
 458         }
 459 
 460         @Override protected void layoutChildren() {
 461             final int w = (int)getWidth();
 462             final int h = (int)getHeight();
 463             final int centerY = (int)(MIN_HEIGHT/2d);
 464             body.resize(w, MIN_HEIGHT);
 465             arrow.setLayoutX(w-GAP-7);
 466             arrow.setLayoutY(centerY-4);
 467             if (this.expansion.get() > 0) {
 468                 if (expandedContent.getParent() == null) {
 469                     getChildren().add(expandedContent);
 470                 }
 471                 expandedContent.setLayoutX(GAP);
 472                 expandedContent.setLayoutY(MIN_HEIGHT);
 473                 final int expandedContentWidth = w - GAP - GAP;
 474                 final int expandedContentHeight = (int)expandedContent.prefHeight(expandedContentWidth);
 475                 expandedContent.resize(expandedContentWidth, expandedContentHeight);
 476             } else {
 477                 getChildren().remove(expandedContent);
 478             }
 479         }
 480         
 481         @Override protected Node impl_pickNodeLocal(double localX, double localY) {
 482             if (contains(localX, localY)) {
 483                 if (this.expansion.get() > 0) {
 484                     final Node superPick = super.impl_pickNodeLocal(localX, localY);
 485                     if (superPick != null) return superPick;
 486                 }
 487                 return this;
 488             }
 489             return null;
 490         }
 491 
 492         // CELL METHODS
 493         @Override protected void updateItem(Speaker speaker, boolean empty) {
 494             super.updateItem(speaker,empty);
 495             final ObservableList<Node> children = getChildren();
 496             body.dividerLine.setVisible(getIndex() != 0);
 497             if (speaker == null) { // empty item
 498                 for (Node child: children) child.setVisible(false);
 499             } else {
 500                 body.name.setText(speaker.getFullName() + (speaker.isRockStar()?" (Rock Star)":""));
 501                 body.company.setText(speaker.getCompany());
 502                 final String bioStr = speaker.getBio();
 503                 if (bioStr == null || bioStr.length() == 0) {
 504                     expandedContent.bioText.setVisible(false);
 505                     expandedContent.bioText.setManaged(false);
 506                     expandedContent.bio.setVisible(false);
 507                     expandedContent.bio.setManaged(false);
 508                 } else {
 509                     expandedContent.bioText.setVisible(true);
 510                     expandedContent.bioText.setManaged(true);
 511                     expandedContent.bio.setVisible(true);
 512                     expandedContent.bio.setManaged(true);
 513                     expandedContent.bio.setText(speaker.getBio());
 514                 }
 515                 final String jobTitleStr = speaker.getJobTitle();
 516                 if (jobTitleStr == null || jobTitleStr.length() == 0) {
 517                     expandedContent.jobTitleText.setVisible(false);
 518                     expandedContent.jobTitleText.setManaged(false);
 519                     expandedContent.jobTitle.setVisible(false);
 520                     expandedContent.jobTitle.setManaged(false);
 521                 } else {
 522                     expandedContent.jobTitleText.setVisible(true);
 523                     expandedContent.jobTitleText.setManaged(true);
 524                     expandedContent.jobTitle.setVisible(true);
 525                     expandedContent.jobTitle.setManaged(true);
 526                     expandedContent.jobTitle.setText(speaker.getJobTitle());
 527                 }
 528                 if (speaker.getImageUrl() != null) {
 529                     Image img = speakerImageCache.get(speaker);
 530                     if (img == null) {
 531                         img = new Image(speaker.getImageUrl(),PIC_SIZE, PIC_SIZE,false,true, true);
 532                         speakerImageCache.put(speaker,img);
 533                     }
 534                     body.image.setImage(img);
 535                 } else {
 536                     body.image.setImage(DUKE_48);
 537                 }  
 538                 if (getIndex() == expandedCellIndex) {
 539                     expansion.set(1);
 540                 } else if (expansion.get() == 1) {
 541                     expansion.set(0);
 542                 }
 543                 arrow.setVisible(true);
 544                 body.setVisible(true);
 545                 expandedContent.sessionsList.getChildren().clear();
 546                 boolean first = true;
 547                 for(final Session session: speaker.getSessions()) {
 548                     for (final SessionTime sessionTime: session.getSessionTimes()) {
 549                         Label title = new Label(session.getTitle()+" @"+DATE_TIME_FORMAT.format(sessionTime.getStart()));
 550                         title.setFont(BASE_FONT);
 551                         title.setTextFill(DARK_GREY);
 552                         title.setPrefHeight(44);
 553                         title.setMaxWidth(Double.MAX_VALUE);
 554                         title.setOnMouseClicked(new SessionClickHandler(session, sessionTime, title));
 555                         updateStar(title, sessionTime.getEvent() != null);
 556                         expandedContent.sessionsList.getChildren().add(title);
 557                         if (!first) {
 558                             title.getStyleClass().add("session-list-item");
 559                         }
 560                         first = false;
 561                     }
 562                     
 563                 }
 564             }
 565         }
 566 
 567         // SKIN METHODS
 568         @Override public SpeakerListCell getSkinnable() { return this; }
 569         @Override public Node getNode() { return body; }
 570         @Override public void dispose() {}
 571     }
 572     
 573     private class SessionClickHandler implements EventHandler<MouseEvent>, Runnable {
 574         private final Session session;
 575         private final SessionTime sessionTime;
 576         private final Label sessionTitle;
 577 
 578         public SessionClickHandler(Session session, SessionTime sessionTime, Label sessionTitle) {
 579             this.session = session;
 580             this.sessionTime = sessionTime;
 581             this.sessionTitle = sessionTitle;
 582         }
 583 
 584         @Override public void handle(MouseEvent t) {
 585             t.consume();
 586             // get/create event
 587             com.javafx.experiments.scheduleapp.model.Event event = sessionTime.getEvent();
 588             if (event == null) {
 589                 Calendar calendar = Calendar.getInstance();
 590                 calendar.setTime(sessionTime.getStart());
 591                 calendar.add(Calendar.MINUTE, sessionTime.getLength());
 592                 event = new com.javafx.experiments.scheduleapp.model.Event(session, sessionTime.getStart(), calendar.getTime(), sessionTime);
 593             }
 594             // show popover
 595             EventPopoverPage page = new EventPopoverPage(dataService, event, false);
 596             popover.clearPages();
 597             popover.pushPage(page);
 598             popover.show();
 599         }
 600 
 601         @Override public void run() {
 602             updateStar(sessionTitle, sessionTime.getEvent() != null);
 603         }
 604         
 605     }
 606 }