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