Syncslides: Slideview UI

Screenshots: https://goo.gl/photos/na5ew2Mk86Nc9rnw5

-Landscape and Portrait support.
-Actionbar and (readonly) notes
-Immersive slide view.
-Simplified the state and components a bit.

Change-Id: Id97d11f6759856010a591242eba7b131e62f2b4e
diff --git a/dart/flutter.yaml b/dart/flutter.yaml
index 47b433e..00ecac9 100644
--- a/dart/flutter.yaml
+++ b/dart/flutter.yaml
@@ -4,6 +4,7 @@
   - name: navigation/arrow_forward
   - name: content/add
   - name: notification/sync
+  - name: maps/layers
 assets:
   - assets/images/defaults/thumbnail.png
   - assets/images/sample_decks/vanadium/1.jpg
diff --git a/dart/lib/components/deckgrid.dart b/dart/lib/components/deckgrid.dart
index 71771e3..f3dbe30 100644
--- a/dart/lib/components/deckgrid.dart
+++ b/dart/lib/components/deckgrid.dart
@@ -23,9 +23,12 @@
   }
 
   Widget build(BuildContext context, AppState appState, AppActions appActions) {
+    // Local decks.
     List<model.Deck> decks = appState.decks.values
-        .where((DeckState d) => d.deck != null)
-        .map((d) => d.deck);
+        .where((DeckState d) => d.deck != null && d.presentation == null)
+        .map((DeckState d) => d.deck);
+
+    // Advertised decks.
     List<model.PresentationAdvertisement> presentations =
         appState.presentationAdvertisements.values;
 
@@ -98,11 +101,11 @@
 
         // Push slides list page first before navigating to the slideshow.
         Navigator.of(context).push(new MaterialPageRoute(
-            builder: (context) => new SlideListPage(presentationData.deck.key,
-                presentationId: presentationData.key)));
+            builder: (context) =>
+                new SlideListPage(presentationData.deck.key)));
         Navigator.of(context).push(new MaterialPageRoute(
-            builder: (context) => new SlideshowPage(presentationData.deck.key,
-                presentationId: presentationData.key)));
+            builder: (context) =>
+                new SlideshowPage(presentationData.deck.key)));
       } catch (e) {
         toast.error(_scaffoldKey,
             'Failed to start presentation ${presentationData.deck.name}.', e);
diff --git a/dart/lib/components/slidelist.dart b/dart/lib/components/slidelist.dart
index a680b35..9de7fea 100644
--- a/dart/lib/components/slidelist.dart
+++ b/dart/lib/components/slidelist.dart
@@ -16,10 +16,8 @@
 
 class SlideListPage extends SyncSlidesPage {
   final String _deckId;
-  final String _presentationId;
 
-  SlideListPage(this._deckId, {String presentationId})
-      : _presentationId = presentationId;
+  SlideListPage(this._deckId);
 
   Widget build(BuildContext context, AppState appState, AppActions appActions) {
     if (!appState.decks.containsKey(_deckId)) {
@@ -42,16 +40,9 @@
 
   _buildPresentFab(
       BuildContext context, AppState appState, AppActions appActions) {
-    bool inPresentation = _presentationId != null &&
-        appState.presentations.containsKey(_presentationId);
-    if (inPresentation) {
-      // Can't present when viewing a presentation.
-      return null;
-    }
-
-    bool alreadyAdvertised = appState.advertisedPresentations
-        .any((model.PresentationAdvertisement p) => p.deck.key == _deckId);
-    if (alreadyAdvertised) {
+    var deckState = appState.decks[_deckId];
+    if (deckState.presentation != null) {
+      // Can't present when already in a presentation.
       return null;
     }
 
@@ -61,12 +52,11 @@
           duration: toast.Durations.permanent);
 
       try {
-        var presentation = await appActions.startPresentation(_deckId);
+        await appActions.startPresentation(_deckId);
         toast.info(_scaffoldKey, 'Presentation started.');
 
         Navigator.of(context).push(new MaterialPageRoute(
-            builder: (context) =>
-                new SlideshowPage(_deckId, presentationId: presentation.key)));
+            builder: (context) => new SlideshowPage(_deckId)));
       } catch (e) {
         toast.error(_scaffoldKey, 'Failed to start presentation.', e);
       }
@@ -76,12 +66,9 @@
 
 class SlideList extends StatelessComponent {
   String _deckId;
-  String _presentationId;
   List<model.Slide> _slides = new List<model.Slide>();
   AppActions _appActions;
-  SlideList(this._deckId, this._slides, this._appActions,
-      {String presentationId})
-      : _presentationId = presentationId;
+  SlideList(this._deckId, this._slides, this._appActions);
 
   Widget build(BuildContext context) {
     NavigatorState navigator = Navigator.of(context);
@@ -90,12 +77,10 @@
         items: _slides,
         itemBuilder: (context, value, index) =>
             _buildSlide(context, _deckId, index, value, onTap: () {
-              _appActions.setCurrSlideNum(_deckId, index,
-                  presentationId: _presentationId);
+              _appActions.setCurrSlideNum(_deckId, index);
 
               navigator.push(new MaterialPageRoute(
-                  builder: (context) => new SlideshowPage(_deckId,
-                      presentationId: _presentationId)));
+                  builder: (context) => new SlideshowPage(_deckId)));
             }));
   }
 }
diff --git a/dart/lib/components/slideshow.dart b/dart/lib/components/slideshow.dart
index cf301f9..8fb94a6 100644
--- a/dart/lib/components/slideshow.dart
+++ b/dart/lib/components/slideshow.dart
@@ -6,20 +6,18 @@
 import 'package:flutter/widgets.dart';
 import 'package:logging/logging.dart';
 
-import '../models/all.dart' as model;
 import '../stores/store.dart';
 import '../styles/common.dart' as style;
 import '../utils/image_provider.dart' as imageProvider;
+import 'slideshow_immersive.dart';
 import 'syncslides_page.dart';
 
-final Logger log = new Logger('store/syncbase_store');
+final Logger log = new Logger('components/slideshow');
 
 class SlideshowPage extends SyncSlidesPage {
   final String _deckId;
-  final String _presentationId;
 
-  SlideshowPage(this._deckId, {String presentationId})
-      : _presentationId = presentationId;
+  SlideshowPage(this._deckId);
 
   Widget build(BuildContext context, AppState appState, AppActions appActions) {
     if (!appState.decks.containsKey(_deckId)) {
@@ -27,67 +25,36 @@
       return new Text('Deck no longer exists.');
     }
 
-    var deckState = appState.decks[_deckId];
-
     return new Scaffold(
-        toolBar: new ToolBar(
-            left: new IconButton(
-                icon: 'navigation/arrow_back',
-                onPressed: () => Navigator.of(context).pop())),
-        floatingActionButton:
-            _buildSyncUpNavigationFab(context, appState, appActions),
         body: new Material(
-            child: new SlideShow(appActions, deckState,
-                appState.presentations[_presentationId])));
-  }
-
-  _buildSyncUpNavigationFab(
-      BuildContext context, AppState appState, AppActions appActions) {
-    if (_presentationId == null) {
-      // Not in a presentation.
-      return null;
-    }
-
-    PresentationState presentationState =
-        appState.presentations[_presentationId];
-
-    // If not navigating out of sync, do not show the sync icon.
-    if (presentationState == null || !presentationState.isNavigationOutOfSync) {
-      return null;
-    }
-
-    return new FloatingActionButton(child: new Icon(icon: 'notification/sync'),
-        onPressed: () async {
-      appActions.syncUpNavigationWithPresentation(_deckId, _presentationId);
-    });
+            child: new SlideShow(appActions, appState.decks[_deckId])));
   }
 }
 
 class SlideShow extends StatelessComponent {
   AppActions _appActions;
   DeckState _deckState;
-  PresentationState _presentationState;
+  NavigatorState _navigator;
+  int _currSlideNum;
 
-  SlideShow(this._appActions, this._deckState, this._presentationState);
+  SlideShow(this._appActions, this._deckState);
 
   Widget build(BuildContext context) {
+    _navigator = Navigator.of(context);
+
     if (_deckState.slides.length == 0) {
       // TODO(aghassemi): Proper error page with navigation back to main view.
       return new Text('No slide to show.');
     }
 
-    int currSlideNum;
-
-    bool isFollowingPresenter =
-        _presentationState != null && !_presentationState.isNavigationOutOfSync;
-
-    if (isFollowingPresenter) {
-      currSlideNum = _presentationState.currSlideNum;
+    if (_deckState.presentation != null &&
+        _deckState.presentation.isFollowingPresentation) {
+      _currSlideNum = _deckState.presentation.currSlideNum;
     } else {
-      currSlideNum = _deckState.currSlideNum;
+      _currSlideNum = _deckState.currSlideNum;
     }
 
-    if (currSlideNum >= _deckState.slides.length) {
+    if (_currSlideNum >= _deckState.slides.length) {
       // TODO(aghassemi): Can this ever happen?
       //  -What if slide number set by another peer is synced before the actual slides?
       //  -What if we have navigated to a particular slide on our own and peer deletes that slide?
@@ -95,53 +62,213 @@
       // maybe for a split second until rest of data syncs up.
       // UI needs to be bullet-roof, a flicker in the UI is better than an exception and crash.
       log.shout(
-          'Current slide number $currSlideNum is greater than the number of slides ${_deckState.slides.length}');
+          'Current slide number $_currSlideNum is greater than the number of slides ${_deckState.slides.length}.');
 
       // TODO(aghassemi): Proper error page with navigation back to main view.
       return new Text('Slide does not exist.');
     }
 
-    var slideData = _deckState.slides[currSlideNum];
-    var image = new AsyncImage(
-        provider: imageProvider.getSlideImage(_deckState.deck.key, slideData),
-        fit: ImageFit.contain);
-    var navWidgets = [
-      _buildSlideNav(currSlideNum - 1),
-      _buildSlideNav(currSlideNum + 1)
-    ];
-
-    return new Block(
-        [image, new Text(currSlideNum.toString()), new Row(navWidgets)]);
+    if (MediaQuery.of(context).orientation == Orientation.landscape) {
+      return _buildLandscapeLayout(context);
+    } else {
+      return _buildPortraitLayout(context);
+    }
   }
 
-  Widget _buildSlideNav(int slideNum) {
-    var card;
+  Widget _buildPortraitLayout(BuildContext context) {
+    // Portrait mode is a column layout divided as 5 parts image, 1 part actionbar
+    // 3 parts notes and 3 parts next/previous navigation thumbnails.
+    var image = new Flexible(child: _buildImage(), flex: 5);
+    var actions = new Flexible(child: _buildActions(), flex: 1);
+    var notes = new Flexible(child: _buildNotes(), flex: 3);
+    var nav = new Flexible(child: _buildPortraitNav(), flex: 3);
+    var layout = new Column([image, actions, notes, nav],
+        alignItems: FlexAlignItems.stretch);
+
+    return layout;
+  }
+
+  Widget _buildLandscapeLayout(BuildContext context) {
+    // Landscape mode is a two column layout.
+    // First column is divided as 5 parts notes, 8 parts parts next/previous navigation thumbnails.
+    // Second column is divided as 11 parts image, 2 parts actionbar.
+    var notes = new Flexible(child: _buildNotes(), flex: 5);
+    var nav = new Flexible(child: _buildLandscapeNav(), flex: 8);
+
+    var image = new Flexible(child: _buildImage(), flex: 11);
+    var actions = new Flexible(child: _buildActions(), flex: 2);
+
+    var notesAndNavColumn = new Flexible(
+        child: new Column([notes, nav], alignItems: FlexAlignItems.stretch),
+        flex: 4);
+    var imageAndActionsColumn = new Flexible(
+        child: new Column([image, actions], alignItems: FlexAlignItems.stretch),
+        flex: 16);
+
+    var layout = new Row([notesAndNavColumn, imageAndActionsColumn],
+        alignItems: FlexAlignItems.stretch);
+
+    return layout;
+  }
+
+  Widget _buildPortraitNav() {
+    return new Row([
+      _buildThumbnailNav(_currSlideNum - 1),
+      _buildThumbnailNav(_currSlideNum + 1)
+    ]);
+  }
+
+  Widget _buildLandscapeNav() {
+    return new Column([
+      _buildThumbnailNav(_currSlideNum - 1),
+      _buildThumbnailNav(_currSlideNum + 1)
+    ]);
+  }
+
+  Widget _buildImage() {
+    var provider = imageProvider.getSlideImage(
+        _deckState.deck.key, _deckState.slides[_currSlideNum]);
+
+    var image = new AsyncImage(provider: provider);
+
+    // If not driving the presentation, tapping the image navigates to the immersive mode.
+    if (_deckState.presentation == null || !_deckState.presentation.isDriving) {
+      image = new InkWell(child: image, onTap: () {
+        _navigator.push(new MaterialPageRoute(
+            builder: (context) =>
+                new SlideshowImmersivePage(_deckState.deck.key)));
+      });
+    }
+
+    return new Row([image],
+        justifyContent: FlexJustifyContent.center,
+        alignItems: FlexAlignItems.stretch);
+  }
+
+  Widget _buildNotes() {
+    // TODO(aghassemi): Notes data.
+    var notes =
+        new Text('Notes (only you see these)', style: style.Text.subtitleStyle);
+    var container = new Container(
+        child: notes,
+        padding: style.Spacing.normalPadding,
+        decoration: new BoxDecoration(
+            border: new Border(
+                bottom: new BorderSide(color: style.theme.dividerColor))));
+    return container;
+  }
+
+  Widget _buildThumbnailNav(int slideNum) {
+    var container;
 
     if (slideNum >= 0 && slideNum < _deckState.slides.length) {
-      var onTap = () => _appActions.setCurrSlideNum(
-          _deckState.deck.key, slideNum,
-          presentationId: _presentationState?.key);
+      var thumbnail = new AsyncImage(
+          provider: imageProvider.getSlideImage(
+              _deckState.deck.key, _deckState.slides[slideNum]),
+          fit: ImageFit.scaleDown);
 
-      card = _buildThumbnailNav(
-          _deckState.deck.key, slideNum, _deckState.slides[slideNum],
-          onTap: onTap);
+      container = new Row([thumbnail]);
+      container = new InkWell(child: container, onTap: () {
+        _appActions.setCurrSlideNum(_deckState.deck.key, slideNum);
+      });
     } else {
-      card = new Container(
-          width: style.Size.thumbnailNavWidth,
-          height: style.Size.thumbnailNavHeight);
+      // Empty grey placeholder.
+      container = new Container(
+          decoration: new BoxDecoration(
+              backgroundColor: style.theme.primarySwatch[100]));
     }
-    // TODO(dynin): overlay 'Previous' / 'Next' text
 
-    return new Container(child: card, margin: style.Spacing.thumbnailNavMargin);
+    return new Flexible(child: container, flex: 1);
   }
-}
 
-Widget _buildThumbnailNav(String deckId, int slideIndex, model.Slide slideData,
-    {Function onTap}) {
-  var thumbnail = new AsyncImage(
-      provider: imageProvider.getSlideImage(deckId, slideData),
-      height: style.Size.thumbnailNavHeight,
-      fit: ImageFit.cover);
+  Widget _buildActions() {
+    // It collects a list of action widgets for the action bar and fabs.
+    // Left contains items that are in-line on the left side of the UI.
+    // Right contains the FABs that hover over the right side of the UI.
+    List<Widget> left = [];
+    List<Widget> right = [];
 
-  return new InkWell(child: thumbnail, onTap: onTap);
+    _buildActions_prev(left, right);
+    _buildActions_slidelist(left, right);
+    _buildActions_next(left, right);
+    _buildActions_followPresentation(left, right);
+
+    return new ToolBar(
+        left: new Row(_buildActions_addMargin(left)), right: right);
+  }
+
+  void _buildActions_prev(List<Widget> left, List<Widget> right) {
+    if (_currSlideNum == 0) {
+      return;
+    }
+    var prev =
+        new InkWell(child: new Icon(icon: 'navigation/arrow_back'), onTap: () {
+      _appActions.setCurrSlideNum(_deckState.deck.key, _currSlideNum - 1);
+    });
+    left.add(prev);
+  }
+
+  void _buildActions_slidelist(List<Widget> left, List<Widget> right) {
+    var slideList =
+        new InkWell(child: new Icon(icon: 'maps/layers'), onTap: () {
+      _navigator.pop();
+    });
+    left.add(slideList);
+  }
+
+  final Matrix4 moveUpFabTransform =
+      new Matrix4.identity().translate(0.0, -27.5);
+
+  void _buildActions_next(List<Widget> left, List<Widget> right) {
+    if (_currSlideNum >= (_deckState.slides.length - 1)) {
+      return;
+    }
+
+    var nextOnTap = () {
+      _appActions.setCurrSlideNum(_deckState.deck.key, _currSlideNum + 1);
+    };
+
+    // If driving the presentation, show a bigger FAB next button on the right side,
+    // otherwise a regular next button on the left side.
+    if (_deckState.presentation != null && _deckState.presentation.isDriving) {
+      var next = new FloatingActionButton(
+          child: new Icon(icon: 'navigation/arrow_forward'),
+          onPressed: nextOnTap);
+
+      var container =
+          new Container(child: next, margin: style.Spacing.fabMargin);
+      next = new Transform(transform: moveUpFabTransform, child: container);
+
+      right.add(next);
+    } else {
+      var next = new InkWell(
+          child: new Icon(icon: 'navigation/arrow_forward'), onTap: nextOnTap);
+      left.add(next);
+    }
+  }
+
+  void _buildActions_followPresentation(List<Widget> left, List<Widget> right) {
+    if (_deckState.presentation == null ||
+        _deckState.presentation.isFollowingPresentation) {
+      return;
+    }
+
+    var syncNav = new FloatingActionButton(
+        child: new Icon(icon: 'notification/sync'), onPressed: () async {
+      _appActions.followPresentation(_deckState.deck.key);
+    });
+
+    syncNav =
+        new Container(child: syncNav, margin: style.Spacing.actionsMargin);
+    syncNav = new Transform(transform: moveUpFabTransform, child: syncNav);
+
+    right.add(syncNav);
+  }
+
+  _buildActions_addMargin(List<Widget> actions) {
+    return actions
+        .map(
+            (w) => new Container(child: w, margin: style.Spacing.actionsMargin))
+        .toList();
+  }
 }
diff --git a/dart/lib/components/slideshow_immersive.dart b/dart/lib/components/slideshow_immersive.dart
new file mode 100644
index 0000000..b6528c0
--- /dev/null
+++ b/dart/lib/components/slideshow_immersive.dart
@@ -0,0 +1,36 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart';
+
+import '../stores/store.dart';
+import '../utils/image_provider.dart' as imageProvider;
+import 'slideshow.dart';
+
+class SlideshowImmersivePage extends SlideshowPage {
+  String _deckId;
+
+  SlideshowImmersivePage(String deckId) : super(deckId) {
+    _deckId = deckId;
+  }
+
+  @override
+  Widget build(BuildContext context, AppState appState, AppActions appActions) {
+    var deckState = appState.decks[_deckId];
+
+    int currSlideNum;
+
+    if (deckState.presentation != null &&
+        deckState.presentation.isFollowingPresentation) {
+      currSlideNum = deckState.presentation.currSlideNum;
+    } else {
+      currSlideNum = deckState.currSlideNum;
+    }
+    var provider = imageProvider.getSlideImage(
+        deckState.deck.key, deckState.slides[currSlideNum]);
+
+    return new AsyncImage(provider: provider);
+  }
+}
diff --git a/dart/lib/discovery/client.dart b/dart/lib/discovery/client.dart
index f9eab6e..72e0668 100644
--- a/dart/lib/discovery/client.dart
+++ b/dart/lib/discovery/client.dart
@@ -59,8 +59,7 @@
   v23discovery.AdvertiserProxy advertiser =
       new v23discovery.AdvertiserProxy.unbound();
   shell.connectToService(v23DiscoveryMojoUrl, advertiser);
-  Future advertiseResponseFuture =
-      advertiser.ptr.advertise(serviceInfo, <String>[]);
+  Future advertiseResponseFuture = advertiser.ptr.advertise(serviceInfo, null);
   _advertiseCalls[presentation.key] =
       new ProxyResponseFuturePair(advertiser, advertiseResponseFuture);
 
diff --git a/dart/lib/stores/actions.dart b/dart/lib/stores/actions.dart
index 093bab8..2c6c085 100644
--- a/dart/lib/stores/actions.dart
+++ b/dart/lib/stores/actions.dart
@@ -25,7 +25,7 @@
   Future setSlides(String deckKey, List<model.Slide> slides);
 
   // Sets the current slide number for a deck.
-  Future setCurrSlideNum(String deckId, int slideNum, {String presentationId});
+  Future setCurrSlideNum(String deckId, int slideNum);
 
   //////////////////////////////////////
   // Presentation
@@ -42,7 +42,7 @@
   // Stops all presentations.
   Future stopAllPresentations();
 
-  // If viewer has started navigating on their own, this will sync the navigation
+  // If viewer has started navigating on their own, this will align the navigation
   // back up with the presentation.
-  Future syncUpNavigationWithPresentation(String deckId, String presentationId);
+  Future followPresentation(String deckId);
 }
diff --git a/dart/lib/stores/state.dart b/dart/lib/stores/state.dart
index a3afb59..c9e099c 100644
--- a/dart/lib/stores/state.dart
+++ b/dart/lib/stores/state.dart
@@ -9,7 +9,6 @@
 // State is deeply-immutable outside of store code.
 abstract class AppState {
   UnmodifiableMapView<String, DeckState> get decks;
-  UnmodifiableMapView<String, PresentationState> get presentations;
   UnmodifiableListView<
       model.PresentationAdvertisement> get advertisedPresentations;
   UnmodifiableMapView<String,
@@ -19,6 +18,7 @@
 abstract class DeckState {
   model.Deck get deck;
   UnmodifiableListView<model.Slide> get slides;
+  PresentationState get presentation;
   int get currSlideNum;
 }
 
@@ -26,5 +26,5 @@
   String get key;
   int get currSlideNum;
   bool get isDriving;
-  bool get isNavigationOutOfSync;
+  bool get isFollowingPresentation;
 }
diff --git a/dart/lib/stores/syncbase/actions.dart b/dart/lib/stores/syncbase/actions.dart
index 2fe3428..978a924 100644
--- a/dart/lib/stores/syncbase/actions.dart
+++ b/dart/lib/stores/syncbase/actions.dart
@@ -34,8 +34,7 @@
     });
   }
 
-  Future setCurrSlideNum(String deckId, int slideNum,
-      {String presentationId}) async {
+  Future setCurrSlideNum(String deckId, int slideNum) async {
     var deckState = _state._getOrCreateDeckState(deckId);
     if (slideNum < 0 || slideNum >= deckState.slides.length) {
       throw new ArgumentError.value(slideNum,
@@ -48,25 +47,18 @@
     deckState._currSlideNum = slideNum;
 
     // Is slide number change happening within a presentation?
-    if (presentationId != null) {
-      if (!_state.presentations.containsKey(presentationId)) {
-        throw new ArgumentError.value(
-            presentationId, "Presentation does not exist.");
-      }
-
-      _PresentationState presentationState =
-          _state.presentations[presentationId];
-
+    if (deckState.presentation != null) {
       // Is the current user driving the presentation?
-      if (presentationState.isDriving) {
+      if (deckState.presentation.isDriving) {
         // Update the common slide number for the presentation.
         sb.SyncbaseTable tb = await _getPresentationsTable();
         await tb.put(
-            keyutil.getPresentationCurrSlideNumKey(deckId, presentationId),
+            keyutil.getPresentationCurrSlideNumKey(
+                deckId, deckState.presentation.key),
             [slideNum]);
       } else {
         // User is not driving the presentation so they are navigating on their own.
-        presentationState._isNavigationOutOfSync = true;
+        deckState.presentation._isFollowingPresentation = false;
       }
     }
     _emitChange();
@@ -119,40 +111,56 @@
   Future joinPresentation(model.PresentationAdvertisement presentation) async {
     bool isMyOwnPresentation =
         _state._advertisedPresentations.any((p) => p.key == presentation.key);
+    String deckId = presentation.deck.key;
 
-    if (!isMyOwnPresentation) {
-      await sb.joinSyncgroup(presentation.syncgroupName);
-      String deckId = presentation.deck.key;
-      Completer completer = new Completer();
-
-      // Wait until at least the slide for current page number is synced.
-      new Timer.periodic(new Duration(milliseconds: 30), (Timer timer) {
-        if (_state._decks.containsKey(deckId) &&
-            _state._decks[deckId].deck != null &&
-            _state._decks[deckId].slides.length >
-                _state._decks[deckId].currSlideNum &&
-            !completer.isCompleted) {
-          timer.cancel();
-          completer.complete();
-        }
-      });
-      await completer.future.timeout(new Duration(seconds: 20));
-    }
-
+    // Set the presentation state for the deck.
+    _DeckState deckState = _state._getOrCreateDeckState(deckId);
     _PresentationState presentationState =
-        _state._getOrCreatePresentationState(presentation.key);
+        deckState._getOrCreatePresentationState(presentation.key);
 
     // TODO(aghassemi): For now, only the presenter can drive. Later when we have
     // identity and delegation support, this will change to: if "driver == me".
     presentationState._isDriving = isMyOwnPresentation;
 
-    log.info('Joined presentation ${presentation.key}');
+    if (!isMyOwnPresentation) {
+      // Wait until at least the slide for current page number is synced.
+      join() async {
+        await sb.joinSyncgroup(presentation.syncgroupName);
+        Completer completer = new Completer();
+        new Timer.periodic(new Duration(milliseconds: 30), (Timer timer) {
+          if (_state._decks.containsKey(deckId) &&
+              _state._decks[deckId].deck != null &&
+              _state._decks[deckId].slides.length >
+                  _state._decks[deckId].currSlideNum &&
+              !completer.isCompleted) {
+            timer.cancel();
+            completer.complete();
+          }
+        });
+        await completer.future.timeout(new Duration(seconds: 20));
+      }
+
+      try {
+        // For for join. If it fails, remove the presentation state from the deck.
+        await join();
+      } catch (e) {
+        deckState._presentation = null;
+        throw e;
+      }
+
+      log.info('Joined presentation ${presentation.key}');
+    }
   }
 
   Future stopPresentation(String presentationId) async {
     await discovery.stopAdvertising(presentationId);
     _state._advertisedPresentations.removeWhere((p) => p.key == presentationId);
-    _state._presentations.remove(presentationId);
+    _state._decks.values.forEach((_DeckState deck) {
+      if (deck.presentation != null &&
+          deck.presentation.key == presentationId) {
+        deck._presentation = null;
+      }
+    });
     log.info('Presentation $presentationId stopped');
   }
 
@@ -164,19 +172,17 @@
     }));
   }
 
-  Future syncUpNavigationWithPresentation(
-      String deckId, String presentationId) async {
-    if (!_state.presentations.containsKey(presentationId)) {
-      throw new ArgumentError.value(
-          presentationId, "Presentation does not exist.");
+  Future followPresentation(String deckId) async {
+    var deckState = _state._getOrCreateDeckState(deckId);
+
+    if (deckState.presentation == null) {
+      throw new ArgumentError.value(deckId, 'Deck is not being presented.');
     }
 
-    _PresentationState presentationState = _state.presentations[presentationId];
-
     // Set the current slide number to the presentation's current slide number.
-    await setCurrSlideNum(deckId, presentationState.currSlideNum);
+    await setCurrSlideNum(deckId, deckState.presentation.currSlideNum);
 
-    presentationState._isNavigationOutOfSync = false;
+    deckState.presentation._isFollowingPresentation = true;
   }
 }
 
diff --git a/dart/lib/stores/syncbase/state.dart b/dart/lib/stores/syncbase/state.dart
index f2206b6..060b284 100644
--- a/dart/lib/stores/syncbase/state.dart
+++ b/dart/lib/stores/syncbase/state.dart
@@ -14,7 +14,6 @@
     presentationAdvertisements =
         new UnmodifiableMapView(_presentationsAdvertisements);
     decks = new UnmodifiableMapView(_decks);
-    presentations = new UnmodifiableMapView(_presentations);
     advertisedPresentations =
         new UnmodifiableListView(_advertisedPresentations);
   }
@@ -22,7 +21,6 @@
   Map<String, model.PresentationAdvertisement> _presentationsAdvertisements =
       new Map();
   Map<String, _DeckState> _decks = new Map();
-  Map<String, _PresentationState> _presentations = new Map();
   List<model.PresentationAdvertisement> _advertisedPresentations = new List();
 
   _DeckState _getOrCreateDeckState(String deckId) {
@@ -30,12 +28,6 @@
       return new _DeckState();
     });
   }
-
-  _PresentationState _getOrCreatePresentationState(String presentationId) {
-    return _presentations.putIfAbsent(presentationId, () {
-      return new _PresentationState(presentationId);
-    });
-  }
 }
 
 class _DeckState implements DeckState {
@@ -45,12 +37,22 @@
   List<model.Slide> _slides = new List();
   List<model.Slide> slides;
 
+  PresentationState _presentation = null;
+  PresentationState get presentation => _presentation;
+
   int _currSlideNum = 0;
   int get currSlideNum => _currSlideNum;
 
   _DeckState() {
     slides = new UnmodifiableListView(_slides);
   }
+
+  _PresentationState _getOrCreatePresentationState(String presentationId) {
+    if (_presentation == null) {
+      _presentation = new _PresentationState(presentationId);
+    }
+    return _presentation;
+  }
 }
 
 class _PresentationState implements PresentationState {
@@ -62,8 +64,8 @@
   bool _isDriving = false;
   bool get isDriving => _isDriving;
 
-  bool _isNavigationOutOfSync = false;
-  bool get isNavigationOutOfSync => _isNavigationOutOfSync;
+  bool _isFollowingPresentation = true;
+  bool get isFollowingPresentation => _isFollowingPresentation;
 
   _PresentationState(this.key);
 }
diff --git a/dart/lib/stores/syncbase/store.dart b/dart/lib/stores/syncbase/store.dart
index 4095383..c4105f9 100644
--- a/dart/lib/stores/syncbase/store.dart
+++ b/dart/lib/stores/syncbase/store.dart
@@ -65,8 +65,14 @@
       _triggerStateChange();
     });
 
-    discovery.onLost.listen((String pId) {
-      _state._presentationsAdvertisements.remove(pId);
+    discovery.onLost.listen((String presentationId) {
+      _state._presentationsAdvertisements.remove(presentationId);
+      _state._decks.values.forEach((_DeckState deck) {
+        if (deck.presentation != null &&
+            deck.presentation.key == presentationId) {
+          deck._presentation = null;
+        }
+      });
       _triggerStateChange();
     });
 
@@ -149,10 +155,13 @@
 
   _onPresentationSlideNumChange(
       int changeType, String rowKey, List<int> value) {
-    var presentationId =
-        keyutil.presentationCurrSlideNumKeyToPresentationId(rowKey);
-    var presentationState =
-        _state._getOrCreatePresentationState(presentationId);
+    String deckId = keyutil.presentationCurrSlideNumKeyToDeckId(rowKey);
+
+    _DeckState deckState = _state._getOrCreateDeckState(deckId);
+    _PresentationState presentationState = deckState.presentation;
+    if (presentationState == null) {
+      return;
+    }
     if (changeType == sb.WatchChangeTypes.put) {
       int currSlideNum = value[0];
       presentationState._currSlideNum = currSlideNum;
diff --git a/dart/lib/stores/utils/key.dart b/dart/lib/stores/utils/key.dart
index 033368d..a8943ad 100644
--- a/dart/lib/stores/utils/key.dart
+++ b/dart/lib/stores/utils/key.dart
@@ -63,15 +63,15 @@
 // TODO(aghassemi): Don't use regex, just regular split should be fine.
 const String _uuidPattern = '[a-zA-Z0-9-]+';
 final RegExp _currPresentationSlideNumPattern =
-    new RegExp('(?:$_uuidPattern/)($_uuidPattern)(?:/currentslide)');
+    new RegExp('($_uuidPattern)(?:/$_uuidPattern)(?:/currentslide)');
 
 // Constructs a current slide number key.
 String getPresentationCurrSlideNumKey(String deckId, String presentationId) {
   return '$deckId/$presentationId/currentslide';
 }
 
-// Gets the presentation id given a current slide number key.
-String presentationCurrSlideNumKeyToPresentationId(String currSlideNumKey) {
+// Gets the deck id given a current slide number key.
+String presentationCurrSlideNumKeyToDeckId(String currSlideNumKey) {
   if ((!isPresentationCurrSlideNumKey(currSlideNumKey))) {
     throw new ArgumentError(
         "$currSlideNumKey is not a valid presentation current slide number key.");
diff --git a/dart/lib/styles/common.dart b/dart/lib/styles/common.dart
index ccbef24..17962e8 100644
--- a/dart/lib/styles/common.dart
+++ b/dart/lib/styles/common.dart
@@ -28,7 +28,8 @@
   static final EdgeDims normalPadding = new EdgeDims.all(10.0);
   static final EdgeDims normalMargin = new EdgeDims.all(2.0);
   static final EdgeDims listItemMargin = new EdgeDims.TRBL(3.0, 6.0, 0.0, 6.0);
-  static final EdgeDims thumbnailNavMargin = new EdgeDims.all(3.0);
+  static final EdgeDims actionsMargin = new EdgeDims.only(right: 20.0);
+  static final EdgeDims fabMargin = new EdgeDims.only(right: 7.0);
 }
 
 class Box {
diff --git a/dart/lib/utils/image_provider.dart b/dart/lib/utils/image_provider.dart
index a716b07..d4029c1 100644
--- a/dart/lib/utils/image_provider.dart
+++ b/dart/lib/utils/image_provider.dart
@@ -11,11 +11,20 @@
 import '../models/all.dart' as model;
 
 ImageProvider getDeckThumbnailImage(model.Deck deck) {
+  if (deck == null) {
+    throw new ArgumentError.notNull('deck');
+  }
   return new _RawImageProvider('thumbnail_${deck.key}', deck.thumbnail);
 }
 
 ImageProvider getSlideImage(String deckId, model.Slide slide) {
-  return new _RawImageProvider('slide_${deckId}_$slide.num', slide.image);
+  if (deckId == null) {
+    throw new ArgumentError.notNull('deckId');
+  }
+  if (slide == null) {
+    throw new ArgumentError.notNull('slide');
+  }
+  return new _RawImageProvider('slide_${deckId}_${slide.num}', slide.image);
 }
 
 class _RawImageProvider implements ImageProvider {
@@ -23,7 +32,6 @@
   final List<int> imageData;
 
   _RawImageProvider(this.imageKey, this.imageData);
-
   Future<ui.Image> loadImage() async {
     return await decodeImageFromList(new Uint8List.fromList(imageData));
   }