syncslides: Support for asking questions and
handing off driving of the presentation.
Screenshots: https://goo.gl/photos/7qAokfLCsGwUZZvP7
-Audience can ask a question
-Presenter can see list of questions:
+ Notification number when questions are added
+ See name, question text and slide num/thumbnail
+ Jump to slide of the question
+ Hand off control to the questioner
+ Resume control
-Images are now centered
-slide x of y overlay
-next/previous overlays
-Drawer menu with the name of the logged in user
-We now use the device id as name for mounting syncbase
so each device now creates the syncgroup on itself.
Change-Id: I65f086b1c5881e5abfcde6a439fcff17b308f47d
diff --git a/dart/Makefile b/dart/Makefile
index 5ae2d85..ec6b260 100644
--- a/dart/Makefile
+++ b/dart/Makefile
@@ -2,24 +2,24 @@
DEVICE_NUM := 1
endif
-ifneq ($(DEVICE_NUM), 1)
- REUSE_FLAG := --reuse-servers
-else
- # TODO(aghassemi): This needs to become $(DEVICE_ID) when we have a way to
- # send the $(DEVICE_ID) to the Flutter app.
- NAME_FLAG := --name=syncslides
-endif
-
-ifdef VLOG
- VLOG_FLAGS = --v=$(VLOG) --logtostderr=true
-endif
-
SYNCBASE_DATA_DIR=/data/data/org.chromium.mojo.shell/app_home/syncbasedata
DEVICE_NUM_PLUS_ONE := $(shell echo $(DEVICE_NUM) \+ 1 | bc)
DEVICE_ID := $(shell adb devices | sed -n $(DEVICE_NUM_PLUS_ONE)p | awk '{ print $$1; }')
DEVICE_FLAG := --target-device $(DEVICE_ID)
MOUNTTABLE_ADDR := /192.168.86.254:8101
-ANDROID_CREDS_DIR := /sdcard/v23creds
+NAME_FLAG := --name=$(DEVICE_ID)
+
+# Currently the only way to pass arguments to the app is by using a file.
+SETTINGS_FILE := /sdcard/syncslides_settings.json
+SETTINGS_JSON := {\"deviceid\": \"$(DEVICE_ID)\", \"mounttable\": \"$(MOUNTTABLE_ADDR)\"}
+
+ifneq ($(DEVICE_NUM), 1)
+ REUSE_FLAG := --reuse-servers
+endif
+
+ifdef VLOG
+ VLOG_FLAGS = --v=$(VLOG) --logtostderr=true
+endif
default: run
@@ -42,6 +42,7 @@
# DEVICE_NUM=1 make run
# DEVICE_NUM=2 make run
run: packages
+ adb -s $(DEVICE_ID) shell 'echo $(SETTINGS_JSON) > $(SETTINGS_FILE)'
pub run flutter_tools build && pub run flutter_tools run_mojo \
--mojo-path $(MOJO_DIR)/src \
--android --mojo-debug -- --enable-multiprocess \
@@ -74,6 +75,7 @@
clean:
rm -f app.flx snapshot_blob.bin
rm -rf packages
+ adb -s $(DEVICE_ID) shell run-as org.chromium.mojo.shell rm $(SETTINGS_FILE)
.PHONY: clean-syncbase
clean-syncbase:
diff --git a/dart/flutter.yaml b/dart/flutter.yaml
index 00ecac9..05af4c9 100644
--- a/dart/flutter.yaml
+++ b/dart/flutter.yaml
@@ -1,8 +1,15 @@
name: syncslides
material-design-icons:
+ - name: action/account_circle
+ - name: action/perm_device_information
+ - name: av/loop
+ - name: av/play_arrow
+ - name: communication/live_help
+ - name: content/add
+ - name: maps/layers
- name: navigation/arrow_back
- name: navigation/arrow_forward
- - name: content/add
+ - name: navigation/menu
- name: notification/sync
- name: maps/layers
assets:
diff --git a/dart/lib/components/askquestion.dart b/dart/lib/components/askquestion.dart
new file mode 100644
index 0000000..e69ac67
--- /dev/null
+++ b/dart/lib/components/askquestion.dart
@@ -0,0 +1,48 @@
+// 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 '../stores/store.dart';
+import 'syncslides_page.dart';
+
+class AskQuestionPage extends SyncSlidesPage {
+ final String _deckId;
+ final int _currSlideNum;
+
+ AskQuestionPage(this._deckId, this._currSlideNum);
+
+ Widget build(BuildContext context, AppState appState, AppActions appActions) {
+ if (!appState.decks.containsKey(_deckId)) {
+ // TODO(aghassemi): Proper error page with navigation back to main view.
+ return new Text('Deck no longer exists.');
+ }
+ var deckState = appState.decks[_deckId];
+ var presentationState = deckState.presentation;
+ if (presentationState == null) {
+ // TODO(aghassemi): Proper error page with navigation back to main view.
+ return new Text('Not in a presentation.');
+ }
+
+ var input = new Input(placeholder: 'Your question',
+ onSubmitted: (String questionText) async {
+ await appActions.askQuestion(
+ deckState.deck.key, _currSlideNum, questionText);
+
+ // TODO(aghassemi): Add a 'Question submitted.' toast on the parent page.
+ // Blocked on https://github.com/flutter/flutter/issues/608
+ Navigator.of(context).pop();
+ });
+
+ var view = new Row([input], alignItems: FlexAlignItems.stretch);
+
+ return new Scaffold(
+ toolBar: new ToolBar(
+ left: new IconButton(
+ icon: 'navigation/arrow_back',
+ onPressed: () => Navigator.of(context).pop()),
+ center: new Text('Ask a question')),
+ body: new Material(child: view));
+ }
+}
diff --git a/dart/lib/components/deckgrid.dart b/dart/lib/components/deckgrid.dart
index f3dbe30..9893e12 100644
--- a/dart/lib/components/deckgrid.dart
+++ b/dart/lib/components/deckgrid.dart
@@ -3,7 +3,6 @@
// license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
-import 'package:flutter/widgets.dart';
import '../models/all.dart' as model;
import '../stores/store.dart';
@@ -13,6 +12,7 @@
import 'slideshow.dart';
import 'syncslides_page.dart';
import 'toast.dart' as toast;
+import 'utils/stop_wrapping.dart';
final GlobalKey _scaffoldKey = new GlobalKey();
@@ -32,9 +32,25 @@
List<model.PresentationAdvertisement> presentations =
appState.presentationAdvertisements.values;
+ Widget title = new Text('SyncSlides');
+ Widget drawer = new IconButton(icon: "navigation/menu", onPressed: () {
+ showDrawer(
+ context: context,
+ child: new Block([
+ new DrawerItem(
+ icon: 'action/account_circle',
+ child: stopWrapping(new Text(appState.user.name,
+ style: style.Text.titleStyle))),
+ new DrawerItem(
+ icon: 'action/perm_device_information',
+ child: stopWrapping(new Text(appState.settings.deviceId,
+ style: style.Text.titleStyle)))
+ ]));
+ });
+
return new Scaffold(
key: _scaffoldKey,
- toolBar: new ToolBar(center: new Text('SyncSlides')),
+ toolBar: new ToolBar(left: drawer, center: title),
floatingActionButton: new FloatingActionButton(
child: new Icon(icon: 'content/add'), onPressed: () {
appActions.loadDemoDeck();
@@ -67,7 +83,7 @@
// TODO(aghassemi): Add "Opened on" data.
var subtitleWidget =
new Text("Opened on Sep 12, 2015", style: style.Text.subtitleStyle);
- subtitleWidget = _stopWrapping(subtitleWidget);
+ subtitleWidget = stopWrapping(subtitleWidget);
var footer = _buildBoxFooter(deckData.name, subtitleWidget);
var box = _buildCard(deckData.key, [thumbnail, footer], () {
Navigator.of(context).push(new MaterialPageRoute(
@@ -116,27 +132,18 @@
Widget _buildBoxFooter(String title, Widget subtitle) {
var titleWidget = new Text(title, style: style.Text.titleStyle);
- titleWidget = _stopWrapping(titleWidget);
+ titleWidget = stopWrapping(titleWidget);
- var titleAndSubtitle = new Block([titleWidget, subtitle]);
+ var titleAndSubtitle = new BlockBody([titleWidget, subtitle]);
return new Container(
child: titleAndSubtitle, padding: style.Spacing.normalPadding);
}
Widget _buildCard(String key, List<Widget> children, Function onTap) {
var content = new Container(
- child: new Card(child: new Block(children)),
+ child: new Card(child: new BlockBody(children)),
margin: style.Spacing.normalMargin);
return new InkWell(key: new Key(key), child: content, onTap: onTap);
}
-
- Widget _stopWrapping(Text child) {
- // TODO(aghassemi): There is no equivalent of CSS's white-space: nowrap,
- // overflow: hidden or text-overflow: ellipsis in Flutter yet.
- // This workaround simulates white-space: nowrap and overflow: hidden.
- // See https://github.com/flutter/flutter/issues/417
- return new Viewport(
- child: child, scrollDirection: ScrollDirection.horizontal);
- }
}
diff --git a/dart/lib/components/questionlist.dart b/dart/lib/components/questionlist.dart
new file mode 100644
index 0000000..7ddcabc
--- /dev/null
+++ b/dart/lib/components/questionlist.dart
@@ -0,0 +1,121 @@
+// 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 '../models/all.dart' as model;
+import '../stores/store.dart';
+import '../styles/common.dart' as style;
+import '../utils/image_provider.dart' as imageProvider;
+import 'syncslides_page.dart';
+import 'toast.dart' as toast;
+
+final GlobalKey _scaffoldKey = new GlobalKey();
+
+class QuestionListPage extends SyncSlidesPage {
+ final String _deckId;
+
+ QuestionListPage(this._deckId);
+
+ Widget build(BuildContext context, AppState appState, AppActions appActions) {
+ if (!appState.decks.containsKey(_deckId)) {
+ // TODO(aghassemi): Proper error page with navigation back to main view.
+ return new Text('Deck no longer exists.');
+ }
+ var deckState = appState.decks[_deckId];
+ var presentationState = deckState.presentation;
+ if (presentationState == null) {
+ // TODO(aghassemi): Proper error page with navigation back to main view.
+ return new Text('Not in a presentation.');
+ }
+ if (!presentationState.isOwner) {
+ // TODO(aghassemi): Proper error page with navigation back to main view.
+ return new Text('Only presentation owner can see the list of questions.');
+ }
+
+ return new Scaffold(
+ key: _scaffoldKey,
+ toolBar: new ToolBar(
+ left: new IconButton(
+ icon: 'navigation/arrow_back',
+ onPressed: () => Navigator.of(context).pop()),
+ center: new Text('Answer questions')),
+ body: new Material(
+ child: new QuestionList(_deckId, presentationState,
+ deckState.slides, appActions, appState)));
+ }
+}
+
+class QuestionList extends StatelessComponent {
+ String _deckId;
+ PresentationState _presentationState;
+ List<model.Slide> _slides;
+ AppActions _appActions;
+ AppState _appState;
+ QuestionList(this._deckId, this._presentationState, this._slides,
+ this._appActions, this._appState);
+
+ Widget build(BuildContext context) {
+ List<Widget> questionCards = _presentationState.questions
+ .map((model.Question q) => _buildQuestionCard(context, q))
+ .toList();
+ return new ScrollableViewport(child: new Block(questionCards));
+ }
+
+ Widget _buildQuestionCard(BuildContext context, model.Question q) {
+ List<Widget> titleChildren = [
+ new Text('Slide ${q.slideNum + 1}', style: style.Text.titleStyle)
+ ];
+
+ Widget jumpToSlide;
+ if (_presentationState.isDriving(_appState.user)) {
+ jumpToSlide = new IconButton(icon: 'av/loop', onPressed: () async {
+ await _appActions.setCurrSlideNum(_deckId, q.slideNum);
+ toast.info(_scaffoldKey, 'Jumped to slide ${q.slideNum + 1}');
+ });
+ titleChildren.add(jumpToSlide);
+ }
+
+ Widget title = new Column([
+ new Text('${q.questioner.name} asked about',
+ style: style.Text.subtitleStyle),
+ new Row(titleChildren)
+ ], alignItems: FlexAlignItems.start);
+
+ Widget thumbnail = new Container(
+ child: new AsyncImage(
+ width: style.Size.questionListThumbnailWidth,
+ provider:
+ imageProvider.getSlideImage(_deckId, _slides[q.slideNum])));
+
+ Widget titleAndThumbnail = new Row([
+ new Flexible(child: title, flex: 2),
+ new Flexible(child: thumbnail, flex: 1)
+ ], alignItems: FlexAlignItems.start);
+
+ Widget question = new Container(
+ child: new BlockBody([titleAndThumbnail, new Text(q.text)]),
+ padding: style.Spacing.normalPadding);
+
+ Widget handoff = new GestureDetector(onTap: () async {
+ await _appActions.setDriver(_deckId, q.questioner);
+ Navigator.of(context).pop();
+ }, child: new Container(child: new Text('HAND OFF')));
+
+ Widget actions = new Container(
+ padding: style.Spacing.normalPadding,
+ decoration: new BoxDecoration(
+ border: new Border(
+ top: new BorderSide(color: style.theme.dividerColor))),
+ child: new DefaultTextStyle(
+ style: new TextStyle(color: style.theme.accentColor),
+ child: new Row([handoff], justifyContent: FlexJustifyContent.end)));
+
+ return new Card(
+ child: new Container(
+ child: new BlockBody([question, actions]),
+ margin: style.Spacing.listItemMargin));
+ }
+}
diff --git a/dart/lib/components/slidelist.dart b/dart/lib/components/slidelist.dart
index 9de7fea..b030704 100644
--- a/dart/lib/components/slidelist.dart
+++ b/dart/lib/components/slidelist.dart
@@ -24,7 +24,6 @@
// TODO(aghassemi): Proper error page with navigation back to main view.
return new Text('Deck no longer exists.');
}
-
var deckState = appState.decks[_deckId];
var slides = deckState.slides;
return new Scaffold(
@@ -46,8 +45,8 @@
return null;
}
- return new FloatingActionButton(
- child: new Icon(icon: 'navigation/arrow_forward'), onPressed: () async {
+ return new FloatingActionButton(child: new Icon(icon: 'av/play_arrow'),
+ onPressed: () async {
toast.info(_scaffoldKey, 'Starting presentation...',
duration: toast.Durations.permanent);
@@ -90,12 +89,12 @@
{Function onTap}) {
var thumbnail = new AsyncImage(
provider: imageProvider.getSlideImage(deckId, slideData),
- height: style.Size.listHeight,
- fit: ImageFit.cover);
+ fit: ImageFit.scaleDown);
- thumbnail = new Flexible(child: thumbnail);
+ thumbnail = new Flexible(child: new Container(child: thumbnail), flex: 0);
- var title = new Text('Slide $slideIndex', style: style.Text.subtitleStyle);
+ var title =
+ new Text('Slide ${slideIndex + 1}', style: style.Text.subtitleStyle);
var notes = new Text(
'This is the teaser slide. It should be memorable and descriptive.');
var titleAndNotes = new Flexible(
diff --git a/dart/lib/components/slideshow.dart b/dart/lib/components/slideshow.dart
index 8fb94a6..ce06c28 100644
--- a/dart/lib/components/slideshow.dart
+++ b/dart/lib/components/slideshow.dart
@@ -3,15 +3,18 @@
// license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
-import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import '../stores/store.dart';
import '../styles/common.dart' as style;
import '../utils/image_provider.dart' as imageProvider;
+import 'askquestion.dart';
+import 'questionlist.dart';
import 'slideshow_immersive.dart';
import 'syncslides_page.dart';
+final GlobalKey _scaffoldKey = new GlobalKey();
+
final Logger log = new Logger('components/slideshow');
class SlideshowPage extends SyncSlidesPage {
@@ -26,18 +29,21 @@
}
return new Scaffold(
+ key: _scaffoldKey,
body: new Material(
- child: new SlideShow(appActions, appState.decks[_deckId])));
+ child:
+ new SlideShow(appActions, appState, appState.decks[_deckId])));
}
}
class SlideShow extends StatelessComponent {
AppActions _appActions;
+ AppState _appState;
DeckState _deckState;
NavigatorState _navigator;
int _currSlideNum;
- SlideShow(this._appActions, this._deckState);
+ SlideShow(this._appActions, this._appState, this._deckState);
Widget build(BuildContext context) {
_navigator = Navigator.of(context);
@@ -76,24 +82,26 @@
}
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);
+ var nav = new Flexible(child: new Row(_buildThumbnailNavs()), flex: 3);
+
+ var items = [image, actions, notes, nav];
+
+ var footer = _buildFooter();
+ if (footer != null) {
+ items.add(footer);
+ }
+
+ var layout = new Column(items, 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 nav = new Flexible(child: new Column(_buildThumbnailNavs()), flex: 8);
var image = new Flexible(child: _buildImage(), flex: 11);
var actions = new Flexible(child: _buildActions(), flex: 2);
@@ -108,31 +116,31 @@
var layout = new Row([notesAndNavColumn, imageAndActionsColumn],
alignItems: FlexAlignItems.stretch);
+ var footer = _buildFooter();
+ if (footer != null) {
+ layout = new Column([new Flexible(child: layout, flex: 8), footer],
+ 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)
- ]);
+ List<Widget> _buildThumbnailNavs() {
+ return <Widget>[
+ _buildThumbnailNav(_currSlideNum - 1, 'Previous'),
+ _buildThumbnailNav(_currSlideNum + 1, 'Next')
+ ];
}
Widget _buildImage() {
var provider = imageProvider.getSlideImage(
_deckState.deck.key, _deckState.slides[_currSlideNum]);
- var image = new AsyncImage(provider: provider);
+ var image = new AsyncImage(provider: provider, fit: ImageFit.scaleDown);
// If not driving the presentation, tapping the image navigates to the immersive mode.
- if (_deckState.presentation == null || !_deckState.presentation.isDriving) {
+ if (_deckState.presentation == null ||
+ !_deckState.presentation.isDriving(_appState.user)) {
image = new InkWell(child: image, onTap: () {
_navigator.push(new MaterialPageRoute(
builder: (context) =>
@@ -140,13 +148,14 @@
});
}
- return new Row([image],
- justifyContent: FlexJustifyContent.center,
- alignItems: FlexAlignItems.stretch);
+ var counter = _buildBubbleOverlay(
+ '${_currSlideNum + 1} of ${_deckState.slides.length}', 0.5, 0.98);
+ image = new Stack([image, counter]);
+
+ return new ClipRect(child: image);
}
Widget _buildNotes() {
- // TODO(aghassemi): Notes data.
var notes =
new Text('Notes (only you see these)', style: style.Text.subtitleStyle);
var container = new Container(
@@ -158,17 +167,17 @@
return container;
}
- Widget _buildThumbnailNav(int slideNum) {
+ Widget _buildThumbnailNav(int slideNum, String label) {
var container;
if (slideNum >= 0 && slideNum < _deckState.slides.length) {
var thumbnail = new AsyncImage(
provider: imageProvider.getSlideImage(
_deckState.deck.key, _deckState.slides[slideNum]),
+ height: style.Size.thumbnailNavHeight,
fit: ImageFit.scaleDown);
- container = new Row([thumbnail]);
- container = new InkWell(child: container, onTap: () {
+ container = new InkWell(child: thumbnail, onTap: () {
_appActions.setCurrSlideNum(_deckState.deck.key, slideNum);
});
} else {
@@ -178,6 +187,10 @@
backgroundColor: style.theme.primarySwatch[100]));
}
+ var nextPreviousBubble = _buildBubbleOverlay(label, 0.5, 0.05);
+ container = new Stack([container, nextPreviousBubble]);
+ container = new ClipRect(child: container);
+
return new Flexible(child: container, flex: 1);
}
@@ -190,6 +203,7 @@
_buildActions_prev(left, right);
_buildActions_slidelist(left, right);
+ _buildActions_question(left, right);
_buildActions_next(left, right);
_buildActions_followPresentation(left, right);
@@ -216,6 +230,45 @@
left.add(slideList);
}
+ void _buildActions_question(List<Widget> left, List<Widget> right) {
+ if (_deckState.presentation == null) {
+ return;
+ }
+
+ // Presentation over is taken to a list of questions view.
+ if (_deckState.presentation.isOwner) {
+ var numQuestions = new FloatingActionButton(
+ child: new Text(_deckState.presentation.questions.length.toString(),
+ style: style.theme.primaryTextTheme.title));
+ // TODO(aghassemi): Find a better way. Scaling down a FAB and
+ // using transform to position it does not seem to be the best approach.
+ final Matrix4 moveUp = new Matrix4.identity().translate(-95.0, 25.0);
+ final Matrix4 scaleDown = new Matrix4.identity().scale(0.3);
+ numQuestions = new Transform(child: numQuestions, transform: moveUp);
+ numQuestions = new Transform(child: numQuestions, transform: scaleDown);
+
+ var questions = new InkWell(
+ child: new Icon(icon: 'communication/live_help'), onTap: () {
+ _navigator.push(new MaterialPageRoute(
+ builder: (context) => new QuestionListPage(_deckState.deck.key)));
+ });
+
+ left.add(questions);
+ left.add(numQuestions);
+ } else {
+ // Audience is taken to ask a question view.
+ var route = new MaterialPageRoute(
+ builder: (context) =>
+ new AskQuestionPage(_deckState.deck.key, _currSlideNum));
+
+ var askQuestion = new InkWell(
+ child: new Icon(icon: 'communication/live_help'), onTap: () {
+ _navigator.push(route);
+ });
+ left.add(askQuestion);
+ }
+ }
+
final Matrix4 moveUpFabTransform =
new Matrix4.identity().translate(0.0, -27.5);
@@ -230,7 +283,8 @@
// 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) {
+ if (_deckState.presentation != null &&
+ _deckState.presentation.isDriving(_appState.user)) {
var next = new FloatingActionButton(
child: new Icon(icon: 'navigation/arrow_forward'),
onPressed: nextOnTap);
@@ -265,10 +319,77 @@
right.add(syncNav);
}
+ Widget _buildFooter() {
+ if (_deckState.presentation == null) {
+ return null;
+ }
+
+ // Owner and not driving?
+ if (_deckState.presentation.isOwner &&
+ !_deckState.presentation.isDriving(_appState.user)) {
+ SnackBarAction resume =
+ new SnackBarAction(label: 'RESUME', onPressed: () {
+ _appActions.setDriver(_deckState.deck.key, _appState.user);
+ });
+
+ return _buildSnackbarFooter('You have handed off control.',
+ action: resume);
+ }
+
+ // Driving but not the owner?
+ if (!_deckState.presentation.isOwner &&
+ _deckState.presentation.isDriving(_appState.user)) {
+ return _buildSnackbarFooter('You are now driving the presentation.');
+ }
+
+ return null;
+ }
+
_buildActions_addMargin(List<Widget> actions) {
return actions
.map(
(w) => new Container(child: w, margin: style.Spacing.actionsMargin))
.toList();
}
+
+ Widget _buildBubbleOverlay(String text, double xOffset, double yOffset) {
+ return new Align(
+ child: new Container(
+ child: new DefaultTextStyle(
+ child: new Text(text), style: Typography.white.body1),
+ decoration: new BoxDecoration(
+ borderRadius: 50.0, // Make the bubble round.
+ backgroundColor:
+ style.Box.bubbleOverlayBackground), // Transparent gray.
+ padding: new EdgeDims.symmetric(horizontal: 5.0, vertical: 2.0)),
+ alignment: new FractionalOffset(xOffset, yOffset));
+ }
+
+ _buildSnackbarFooter(String lable, {SnackBarAction action}) {
+ var text = new Text(lable);
+ text = new DefaultTextStyle(style: Typography.white.subhead, child: text);
+ List<Widget> children = <Widget>[
+ new Flexible(
+ child: new Container(
+ margin: style.Spacing.footerVerticalMargin,
+ child: new DefaultTextStyle(
+ style: Typography.white.subhead, child: text)))
+ ];
+
+ if (action != null) {
+ children.add(action);
+ }
+
+ var clipper = new ClipRect(
+ child: new Material(
+ elevation: 6,
+ color: style.Box.footerBackground,
+ child: new Container(
+ margin: style.Spacing.footerHorizontalMargin,
+ child: new DefaultTextStyle(
+ style: new TextStyle(color: style.theme.accentColor),
+ child: new Row(children)))));
+
+ return new Flexible(child: clipper, flex: 1);
+ }
}
diff --git a/dart/lib/components/toast.dart b/dart/lib/components/toast.dart
index 488601d..841324f 100644
--- a/dart/lib/components/toast.dart
+++ b/dart/lib/components/toast.dart
@@ -5,9 +5,12 @@
import 'dart:core';
import 'package:flutter/material.dart';
+import 'package:logging/logging.dart';
import '../styles/common.dart' as style;
+final Logger log = new Logger('components/toast');
+
class Durations {
static const Duration permanent = const Duration(days: 100);
static const Duration long = const Duration(seconds: 5);
@@ -31,6 +34,8 @@
// TODO(aghassemi): Add "Details" action to error toasts and move error text there.
content: new Text(text + ' - ERROR: $err', style: style.Text.error),
duration: duration));
+
+ log.severe(err);
}
void _closePrevious() {
diff --git a/dart/lib/components/utils/stop_wrapping.dart b/dart/lib/components/utils/stop_wrapping.dart
new file mode 100644
index 0000000..7c4bd17
--- /dev/null
+++ b/dart/lib/components/utils/stop_wrapping.dart
@@ -0,0 +1,14 @@
+// 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';
+
+Widget stopWrapping(Text child) {
+ // TODO(aghassemi): There is no equivalent of CSS's white-space: nowrap,
+ // overflow: hidden or text-overflow: ellipsis in Flutter yet.
+ // This workaround simulates white-space: nowrap and overflow: hidden.
+ // See https://github.com/flutter/flutter/issues/417
+ return new Viewport(
+ child: child, scrollDirection: ScrollDirection.horizontal);
+}
diff --git a/dart/lib/config.dart b/dart/lib/config.dart
deleted file mode 100644
index 1ba0489..0000000
--- a/dart/lib/config.dart
+++ /dev/null
@@ -1,8 +0,0 @@
-// 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.
-
-// TODO(aghassemi): Make these configurable from command line and/or UI.
-bool DemoEnabled = true;
-
-String mounttableAddr = '/192.168.86.254:8101';
diff --git a/dart/lib/identity/client.dart b/dart/lib/identity/client.dart
new file mode 100644
index 0000000..91de45b
--- /dev/null
+++ b/dart/lib/identity/client.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 'dart:async';
+
+import 'package:flutter/services.dart' show shell;
+import 'package:logging/logging.dart';
+import 'package:mojo_services/authentication/authentication.mojom.dart' as auth;
+
+import '../models/all.dart' as model;
+import '../settings/client.dart' as settings;
+
+final Logger log = new Logger('identity/client');
+
+// TODO(aghassemi): Switch to using the principal service.
+// See https://github.com/vanadium/issues/issues/955
+const String authenticationUrl = 'mojo:authentication';
+
+Future<model.User> getUser() async {
+ model.Settings s = await settings.getSettings();
+
+ auth.AuthenticationServiceProxy authenticator =
+ new auth.AuthenticationServiceProxy.unbound();
+
+ shell.connectToService(authenticationUrl, authenticator);
+ var account = await authenticator.ptr.selectAccount(true);
+
+ // TODO(aghassemi): How do I get the blessing name from the username?
+ // I don't think the following is correct as it seems the actual blessing
+ // has an app specific prefix.
+ // See https://github.com/vanadium/issues/issues/955
+ // See https://github.com/vanadium/issues/issues/956
+ var blessing = 'dev.v.io:u:${account.username}';
+ return new model.User(account.username, blessing, s.deviceId);
+}
diff --git a/dart/lib/main.dart b/dart/lib/main.dart
index 6ef619e..ab0ed02 100644
--- a/dart/lib/main.dart
+++ b/dart/lib/main.dart
@@ -5,20 +5,23 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
-import 'styles/common.dart' as style;
import 'components/deckgrid.dart';
+import 'stores/store.dart';
+import 'styles/common.dart' as style;
import 'utils/back_button.dart' as backButtonUtil;
NavigatorState _navigator;
void main() {
+ var store = new Store.singleton();
_initLogging();
_initBackButtonHandler();
- runApp(new MaterialApp(
+ // TODO(aghassemi): Splash screen while store is initializing.
+ store.init().then((_) => runApp(new MaterialApp(
theme: style.theme,
title: 'SyncSlides',
- routes: {'/': (RouteArguments args) => new LandingPage()}));
+ routes: {'/': (RouteArguments args) => new LandingPage()})));
}
class LandingPage extends StatelessComponent {
diff --git a/dart/lib/models/all.dart b/dart/lib/models/all.dart
index c802c0b..e2dbdbd 100644
--- a/dart/lib/models/all.dart
+++ b/dart/lib/models/all.dart
@@ -3,5 +3,8 @@
// license that can be found in the LICENSE file.
export 'deck.dart';
-export 'slide.dart';
export 'presentation_advertisement.dart';
+export 'question.dart';
+export 'settings.dart';
+export 'slide.dart';
+export 'user.dart';
diff --git a/dart/lib/models/question.dart b/dart/lib/models/question.dart
new file mode 100644
index 0000000..cc7fd73
--- /dev/null
+++ b/dart/lib/models/question.dart
@@ -0,0 +1,46 @@
+// 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 'dart:convert';
+
+import 'user.dart';
+
+class Question {
+ // TODO(aghassemi): Fix inconsistencies between key and id everywhere.
+ String _id;
+ String get id => _id;
+
+ String _text;
+ String get text => _text;
+
+ int _slideNum;
+ int get slideNum => _slideNum;
+
+ User _questioner;
+ User get questioner => _questioner;
+
+ DateTime _timestamp;
+ DateTime get timestamp => _timestamp;
+
+ Question(
+ this._id, this._text, this._slideNum, this._questioner, this._timestamp);
+
+ Question.fromJson(String id, String json) {
+ Map map = JSON.decode(json);
+ _id = id;
+ _text = map['text'];
+ _slideNum = map['slidenum'];
+ _questioner = new User.fromJson(map['questioner']);
+ _timestamp = new DateTime.fromMillisecondsSinceEpoch(map['timestamp']);
+ }
+
+ String toJson() {
+ Map map = new Map();
+ map['text'] = text;
+ map['slidenum'] = slideNum;
+ map['questioner'] = questioner.toJson();
+ map['timestamp'] = timestamp.millisecondsSinceEpoch;
+ return JSON.encode(map);
+ }
+}
diff --git a/dart/lib/models/settings.dart b/dart/lib/models/settings.dart
new file mode 100644
index 0000000..cfb1638
--- /dev/null
+++ b/dart/lib/models/settings.dart
@@ -0,0 +1,28 @@
+// 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 'dart:convert';
+
+class Settings {
+ String _deviceId;
+ String get deviceId => _deviceId;
+
+ String _mounttable;
+ String get mounttable => _mounttable;
+
+ Settings(this._deviceId, this._mounttable);
+
+ Settings.fromJson(String json) {
+ Map map = JSON.decode(json);
+ _deviceId = map['deviceid'];
+ _mounttable = map['mounttable'];
+ }
+
+ String toJson() {
+ Map map = new Map();
+ map['deviceid'] = deviceId;
+ map['mounttable'] = mounttable;
+ return JSON.encode(map);
+ }
+}
diff --git a/dart/lib/models/user.dart b/dart/lib/models/user.dart
new file mode 100644
index 0000000..15e9607
--- /dev/null
+++ b/dart/lib/models/user.dart
@@ -0,0 +1,33 @@
+// 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 'dart:convert';
+
+class User {
+ String _name;
+ String get name => _name;
+
+ String _blessing;
+ String get blessing => _blessing;
+
+ String _deviceId;
+ String get deviceId => _deviceId;
+
+ User(this._name, this._blessing, this._deviceId);
+
+ User.fromJson(String json) {
+ Map map = JSON.decode(json);
+ _name = map['name'];
+ _blessing = map['blessing'];
+ _deviceId = map['deviceid'];
+ }
+
+ String toJson() {
+ Map map = new Map();
+ map['name'] = name;
+ map['blessing'] = blessing;
+ map['deviceid'] = deviceId;
+ return JSON.encode(map);
+ }
+}
diff --git a/dart/lib/settings/client.dart b/dart/lib/settings/client.dart
new file mode 100644
index 0000000..e662b0b
--- /dev/null
+++ b/dart/lib/settings/client.dart
@@ -0,0 +1,15 @@
+// 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 'dart:async';
+import 'dart:io';
+
+import '../models/all.dart' as model;
+
+const String settingsFilePath = '/sdcard/syncslides_settings.json';
+
+Future<model.Settings> getSettings() async {
+ String settingsJson = await new File(settingsFilePath).readAsString();
+ return new model.Settings.fromJson(settingsJson);
+}
diff --git a/dart/lib/stores/actions.dart b/dart/lib/stores/actions.dart
index 2c6c085..678d30a 100644
--- a/dart/lib/stores/actions.dart
+++ b/dart/lib/stores/actions.dart
@@ -45,4 +45,10 @@
// If viewer has started navigating on their own, this will align the navigation
// back up with the presentation.
Future followPresentation(String deckId);
+
+ // Adds a question to a specific slide within a presentation.
+ Future askQuestion(String deckId, int slideNum, String questionText);
+
+ // Sets the driver of a presentation to the given user.
+ Future setDriver(String deckId, model.User driver);
}
diff --git a/dart/lib/stores/state.dart b/dart/lib/stores/state.dart
index c9e099c..34599c5 100644
--- a/dart/lib/stores/state.dart
+++ b/dart/lib/stores/state.dart
@@ -8,23 +8,69 @@
// Application is rendered purely based on this state.
// State is deeply-immutable outside of store code.
abstract class AppState {
+ // Current user.
+ model.User get user;
+
+ // Current settings of the app.
+ model.Settings get settings;
+
+ // List of decks.
UnmodifiableMapView<String, DeckState> get decks;
+
+ // List of presentations advertised by this instance of the app.
UnmodifiableListView<
model.PresentationAdvertisement> get advertisedPresentations;
+
+ // List of presentations advertised by others.
UnmodifiableMapView<String,
model.PresentationAdvertisement> get presentationAdvertisements;
}
abstract class DeckState {
+ // The deck.
model.Deck get deck;
+
+ // List of slides.
UnmodifiableListView<model.Slide> get slides;
+
+ // State of the presentation for a deck.
+ // null if deck is not in a presentation.
PresentationState get presentation;
+
+ // Local current slide number for the deck.
int get currSlideNum;
}
abstract class PresentationState {
+ // Presentation id.
String get key;
+
+ // Shared slide number for the presentation.
int get currSlideNum;
- bool get isDriving;
+
+ // User who is driving the presentation.
+ model.User get driver;
+
+ // Whether current user owns the presentation.
+ bool get isOwner;
+
+ // Whether current user is following the presentation or has started
+ // navigating on their own.
bool get isFollowingPresentation;
+
+ // Questions asked in the presentation.
+ UnmodifiableListView<model.Question> get questions;
+
+ // Returns true if the given user is driving the presentation.
+ bool isDriving(model.User currUser) {
+ // TODO(aghassemi): We currently check the deviceId in addition to the blessing
+ // to decide if current user is the driver.
+ // This can change in the future when we have the concept of user sessions and sync
+ // all of a user's data across their own devices, but currently, same user using a different
+ // device is treated like another user everywhere else in the UI so there is no point in
+ // deviating from that behaviour for driving a presentation.
+ return this.driver != null &&
+ this.driver.blessing == currUser.blessing &&
+ this.driver.deviceId == currUser.deviceId;
+ }
}
diff --git a/dart/lib/stores/store.dart b/dart/lib/stores/store.dart
index da994d5..3c980dc 100644
--- a/dart/lib/stores/store.dart
+++ b/dart/lib/stores/store.dart
@@ -24,4 +24,8 @@
AppActions get actions;
AppState get state;
Stream get onStateChange;
+
+ // Initializes the store and loads parts of the state that are required before
+ // the application can start, such as the user information and settings.
+ Future init();
}
diff --git a/dart/lib/stores/syncbase/actions.dart b/dart/lib/stores/syncbase/actions.dart
index 978a924..8513b06 100644
--- a/dart/lib/stores/syncbase/actions.dart
+++ b/dart/lib/stores/syncbase/actions.dart
@@ -49,7 +49,7 @@
// Is slide number change happening within a presentation?
if (deckState.presentation != null) {
// Is the current user driving the presentation?
- if (deckState.presentation.isDriving) {
+ if (deckState.presentation.isDriving(_state.user)) {
// Update the common slide number for the presentation.
sb.SyncbaseTable tb = await _getPresentationsTable();
await tb.put(
@@ -89,13 +89,13 @@
}
String uuid = uuidutil.createUuid();
- String syncgroupName = keyutil.getPresentationSyncgroupName(uuid);
+ String syncgroupName = _getPresentationSyncgroupName(_state.settings, uuid);
model.Deck deck = _state._getOrCreateDeckState(deckId)._deck;
var presentation =
new model.PresentationAdvertisement(uuid, deck, syncgroupName);
- await sb.createSyncgroup(syncgroupName, [
+ await sb.createSyncgroup(_state.settings.mounttable, syncgroupName, [
sb.SyncbaseClient.syncgroupPrefix(decksTableName, deckId),
sb.SyncbaseClient.syncgroupPrefix(presentationsTableName, deckId)
]);
@@ -103,53 +103,77 @@
await discovery.advertise(presentation);
_state._advertisedPresentations.add(presentation);
- await joinPresentation(presentation);
+ // Set the presentation state for the deck.
+ _DeckState deckState = _state._getOrCreateDeckState(deckId);
+ _PresentationState presentationstate =
+ deckState._getOrCreatePresentationState(presentation.key);
+ presentationstate._isOwner = true;
+
+ setDefaultsAndJoin() async {
+ // Set the current slide number to 0.
+ sb.SyncbaseTable tb = await _getPresentationsTable();
+ await tb.put(
+ keyutil.getPresentationCurrSlideNumKey(deckId, presentation.key),
+ [0]);
+
+ // Set the current user as the driver.
+ await _setPresentationDriver(deckId, presentation.key, _state.user);
+
+ // Also join the presentation.
+ await joinPresentation(presentation);
+ }
+
+ try {
+ // Wait for join. If it fails, remove the presentation state from the deck.
+ await setDefaultsAndJoin();
+ } catch (e) {
+ deckState._presentation = null;
+ throw e;
+ }
return presentation;
}
Future joinPresentation(model.PresentationAdvertisement presentation) async {
- bool isMyOwnPresentation =
- _state._advertisedPresentations.any((p) => p.key == presentation.key);
String deckId = presentation.deck.key;
// Set the presentation state for the deck.
_DeckState deckState = _state._getOrCreateDeckState(deckId);
- _PresentationState presentationState =
- deckState._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;
-
- if (!isMyOwnPresentation) {
- // Wait until at least the slide for current page number is synced.
- join() async {
+ // Wait until at least the current slide number, driver and the slide for current slide number is synced.
+ join() async {
+ bool isMyOwnPresentation =
+ _state._advertisedPresentations.any((p) => p.key == presentation.key);
+ if (!isMyOwnPresentation) {
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}');
+ 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 &&
+ _state._decks[deckId].presentation != null &&
+ _state._decks[deckId].presentation.driver != null &&
+ !completer.isCompleted) {
+ timer.cancel();
+ completer.complete();
+ }
+ });
+ await completer.future.timeout(new Duration(seconds: 20));
}
+
+ try {
+ // Wait 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 {
@@ -184,11 +208,53 @@
deckState.presentation._isFollowingPresentation = true;
}
+
+ Future askQuestion(String deckId, int slideNum, String questionText) async {
+ var deckState = _state._getOrCreateDeckState(deckId);
+
+ if (deckState.presentation == null) {
+ throw new ArgumentError.value(deckId,
+ 'Cannot ask a question because deck is not part of a presentation');
+ }
+
+ sb.SyncbaseTable tb = await _getPresentationsTable();
+ String questionId = uuidutil.createUuid();
+
+ model.Question question = new model.Question(
+ questionId, questionText, slideNum, _state.user, new DateTime.now());
+
+ var key = keyutil.getPresentationQuestionKey(
+ deckId, deckState.presentation.key, questionId);
+
+ await tb.put(key, UTF8.encode(question.toJson()));
+ }
+
+ Future setDriver(String deckId, model.User driver) async {
+ var deckState = _state._getOrCreateDeckState(deckId);
+
+ if (deckState.presentation == null) {
+ throw new ArgumentError.value(deckId,
+ 'Cannot set the driver because deck is not part of a presentation');
+ }
+ await _setPresentationDriver(deckId, deckState.presentation.key, driver);
+ }
}
//////////////////////////////////////
// Utilities
+Future _setPresentationDriver(
+ String deckId, String presentationId, model.User driver) async {
+ sb.SyncbaseTable tb = await _getPresentationsTable();
+ await tb.put(keyutil.getPresentationDriverKey(deckId, presentationId),
+ UTF8.encode(driver.toJson()));
+}
+
+String _getPresentationSyncgroupName(
+ model.Settings settings, String presentationId) {
+ return '${settings.mounttable}/${settings.deviceId}/%%sync/$presentationId';
+}
+
Future<sb.SyncbaseTable> _getTable(String tableName) async {
sb.SyncbaseDatabase sbDb = await sb.getDatabase();
sb.SyncbaseTable tb = sbDb.table(tableName);
diff --git a/dart/lib/stores/syncbase/state.dart b/dart/lib/stores/syncbase/state.dart
index 060b284..f859fd0 100644
--- a/dart/lib/stores/syncbase/state.dart
+++ b/dart/lib/stores/syncbase/state.dart
@@ -4,24 +4,30 @@
part of syncbase_store;
-class _AppState implements AppState {
- List<model.PresentationAdvertisement> advertisedPresentations;
- Map<String, model.PresentationAdvertisement> presentationAdvertisements;
- Map<String, DeckState> decks;
- Map<String, PresentationState> presentations;
+class _AppState extends AppState {
+ model.User get user => _user;
+ model.Settings get settings => _settings;
+ UnmodifiableMapView<String, DeckState> decks;
+ UnmodifiableListView<model.PresentationAdvertisement> advertisedPresentations;
+ UnmodifiableMapView<String,
+ model.PresentationAdvertisement> presentationAdvertisements;
_AppState() {
- presentationAdvertisements =
- new UnmodifiableMapView(_presentationsAdvertisements);
+ _user = null;
+ _settings = null;
decks = new UnmodifiableMapView(_decks);
advertisedPresentations =
new UnmodifiableListView(_advertisedPresentations);
+ presentationAdvertisements =
+ new UnmodifiableMapView(_presentationsAdvertisements);
}
- Map<String, model.PresentationAdvertisement> _presentationsAdvertisements =
- new Map();
+ model.User _user;
+ model.Settings _settings;
Map<String, _DeckState> _decks = new Map();
List<model.PresentationAdvertisement> _advertisedPresentations = new List();
+ Map<String, model.PresentationAdvertisement> _presentationsAdvertisements =
+ new Map();
_DeckState _getOrCreateDeckState(String deckId) {
return _decks.putIfAbsent(deckId, () {
@@ -30,12 +36,12 @@
}
}
-class _DeckState implements DeckState {
+class _DeckState extends DeckState {
model.Deck _deck;
model.Deck get deck => _deck;
List<model.Slide> _slides = new List();
- List<model.Slide> slides;
+ UnmodifiableListView<model.Slide> slides;
PresentationState _presentation = null;
PresentationState get presentation => _presentation;
@@ -55,17 +61,25 @@
}
}
-class _PresentationState implements PresentationState {
+class _PresentationState extends PresentationState {
final String key;
int _currSlideNum = 0;
int get currSlideNum => _currSlideNum;
- bool _isDriving = false;
- bool get isDriving => _isDriving;
+ model.User _driver;
+ model.User get driver => _driver;
+
+ bool _isOwner = false;
+ bool get isOwner => _isOwner;
bool _isFollowingPresentation = true;
bool get isFollowingPresentation => _isFollowingPresentation;
- _PresentationState(this.key);
+ List<model.Question> _questions = new List();
+ UnmodifiableListView<model.Question> questions;
+
+ _PresentationState(this.key) {
+ questions = new UnmodifiableListView(_questions);
+ }
}
diff --git a/dart/lib/stores/syncbase/store.dart b/dart/lib/stores/syncbase/store.dart
index c4105f9..252f6d7 100644
--- a/dart/lib/stores/syncbase/store.dart
+++ b/dart/lib/stores/syncbase/store.dart
@@ -11,8 +11,10 @@
import 'package:logging/logging.dart';
import '../../discovery/client.dart' as discovery;
+import '../../identity/client.dart' as identity;
import '../../loaders/loader.dart';
import '../../models/all.dart' as model;
+import '../../settings/client.dart' as settings;
import '../../syncbase/client.dart' as sb;
import '../../utils/errors.dart' as errorsutil;
import '../../utils/uuid.dart' as uuidutil;
@@ -20,8 +22,8 @@
import '../utils/key.dart' as keyutil;
part 'actions.dart';
-part 'state.dart';
part 'consts.dart';
+part 'state.dart';
// Implementation of Store using Syncbase (http://v.io/syncbase) storage system.
class SyncbaseStore implements Store {
@@ -36,18 +38,34 @@
SyncbaseStore() {
_state = new _AppState();
_actions = new _AppActions(_state, _triggerStateChange);
+ }
- sb.getDatabase().then((sb.SyncbaseDatabase db) async {
- // Make sure all table exists.
- await _ensureTablesExist();
+ Future init() async {
+ // Wait for synchronous initializers.
+ await _syncInits();
- // TODO(aghassemi): Use the multi-table scan and watch API when ready.
- // See https://github.com/vanadium/issues/issues/923
- for (String table in [decksTableName, presentationsTableName]) {
- _getInitialValuesAndStartWatching(db, table);
- }
- _startScanningForPresentations();
- });
+ // Don't wait for async ones.
+ _asyncInits();
+ }
+
+ // Initializations that we must wait for before considering store initialized.
+ Future _syncInits() async {
+ _state._user = await identity.getUser();
+ _state._settings = await settings.getSettings();
+ }
+
+ // Initializations that can be done asynchronously. We do not need to wait for
+ // these before considering store initalized.
+ Future _asyncInits() async {
+ // TODO(aghassemi): Use the multi-table scan and watch API when ready.
+ // See https://github.com/vanadium/issues/issues/923
+ sb.SyncbaseDatabase db = await sb.getDatabase();
+ // Make sure all tables exist.
+ await _ensureTablesExist();
+ for (String table in [decksTableName, presentationsTableName]) {
+ _getInitialValuesAndStartWatching(db, table);
+ }
+ _startScanningForPresentations();
}
// Note(aghassemi): We could have copied the state to provide a snapshot at the time of
@@ -120,6 +138,12 @@
case keyutil.KeyType.PresentationCurrSlideNum:
_onPresentationSlideNumChange(changeType, rowKey, value);
break;
+ case keyutil.KeyType.PresentationDriver:
+ _onPresentationDriverChange(changeType, rowKey, value);
+ break;
+ case keyutil.KeyType.PresentationQuestion:
+ _onPresentationQuestionChange(changeType, rowKey, value);
+ break;
case keyutil.KeyType.Unknown:
log.severe('Got change for $rowKey with an unknown key type.');
}
@@ -170,6 +194,51 @@
}
}
+ _onPresentationDriverChange(int changeType, String rowKey, List<int> value) {
+ String deckId = keyutil.presentationDriverKeyToDeckId(rowKey);
+
+ _DeckState deckState = _state._getOrCreateDeckState(deckId);
+ _PresentationState presentationState = deckState.presentation;
+ if (presentationState == null) {
+ return;
+ }
+
+ if (changeType == sb.WatchChangeTypes.put) {
+ model.User driver = new model.User.fromJson(UTF8.decode(value));
+ presentationState._driver = driver;
+ log.info('${driver.name} is now driving the presentation.');
+ } else {
+ presentationState._driver = _state.user;
+ }
+ }
+
+ _onPresentationQuestionChange(
+ int changeType, String rowKey, List<int> value) {
+ String deckId = keyutil.presentationQuestionKeyToDeckId(rowKey);
+
+ _DeckState deckState = _state._getOrCreateDeckState(deckId);
+ _PresentationState presentationState = deckState.presentation;
+ if (presentationState == null) {
+ return;
+ }
+
+ String questionId = keyutil.presentationQuestionKeyToQuestionId(rowKey);
+
+ if (changeType == sb.WatchChangeTypes.put) {
+ model.Question question =
+ new model.Question.fromJson(questionId, UTF8.decode(value));
+ presentationState._questions.add(question);
+ } else {
+ presentationState._questions
+ .removeWhere((model.Question q) => q.id == questionId);
+ }
+
+ // Keep questions sorted by timestamp.
+ presentationState._questions.sort((model.Question a, model.Question b) {
+ return a.timestamp.compareTo(b.timestamp);
+ });
+ }
+
Future _ensureTablesExist() async {
await _getDecksTable();
await _getPresentationsTable();
diff --git a/dart/lib/stores/utils/key.dart b/dart/lib/stores/utils/key.dart
index a8943ad..83b8e1a 100644
--- a/dart/lib/stores/utils/key.dart
+++ b/dart/lib/stores/utils/key.dart
@@ -2,9 +2,14 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-import '../../config.dart' as config;
-
-enum KeyType { Deck, Slide, PresentationCurrSlideNum, Unknown }
+enum KeyType {
+ Deck,
+ Slide,
+ PresentationCurrSlideNum,
+ PresentationDriver,
+ PresentationQuestion,
+ Unknown
+}
KeyType getKeyType(String key) {
if (isDeckKey(key)) {
@@ -13,6 +18,10 @@
return KeyType.Slide;
} else if (isPresentationCurrSlideNumKey(key)) {
return KeyType.PresentationCurrSlideNum;
+ } else if (isPresentationDriverKey(key)) {
+ return KeyType.PresentationDriver;
+ } else if (isPresentationQuestionKey(key)) {
+ return KeyType.PresentationQuestion;
} else {
return KeyType.Unknown;
}
@@ -46,7 +55,7 @@
// Gets the deck id given a slide key.
String currSlideKeyToDeckId(String key) {
if ((!isSlideKey(key))) {
- throw new ArgumentError("$key is not a valid slide key.");
+ throw new ArgumentError('$key is not a valid slide key.');
}
return key.substring(0, key.indexOf('/slides/'));
}
@@ -54,7 +63,7 @@
// Gets the slide index given a slide key.
int currSlideKeyToIndex(String key) {
if ((!isSlideKey(key))) {
- throw new ArgumentError("$key is not a valid slide key.");
+ throw new ArgumentError('$key is not a valid slide key.');
}
var indexStr = key.substring(key.lastIndexOf('/') + 1);
return int.parse(indexStr);
@@ -74,7 +83,7 @@
String presentationCurrSlideNumKeyToDeckId(String currSlideNumKey) {
if ((!isPresentationCurrSlideNumKey(currSlideNumKey))) {
throw new ArgumentError(
- "$currSlideNumKey is not a valid presentation current slide number key.");
+ '$currSlideNumKey is not a valid presentation current slide number key.');
}
return _currPresentationSlideNumPattern.firstMatch(currSlideNumKey).group(1);
}
@@ -84,12 +93,51 @@
return _currPresentationSlideNumPattern.hasMatch(key);
}
-// Constructs the Syncgroup name for a presentation.
-String getPresentationSyncgroupName(String presentationId) {
- // TODO(aghassemi): Currently we are assuming the first device
- // mounts itself under the name 'syncslides' and then every other device
- // creates the Syncgroup on the first device.
- // We should have each device mount itself under a unique name and
- // to create the Syncgroup on their own instance.
- return '${config.mounttableAddr}/syncslides/%%sync/$presentationId';
+// TODO(aghassemi): Don't use regex, just regular split should be fine.
+final RegExp _presentationDriverPattern =
+ new RegExp('($_uuidPattern)(?:/$_uuidPattern)(?:/driver)');
+// Constructs a presentation driver key.
+String getPresentationDriverKey(String deckId, String presentationId) {
+ return '$deckId/$presentationId/driver';
+}
+
+// Gets the deck id given a presentation driver key.
+String presentationDriverKeyToDeckId(String driverKey) {
+ if ((!isPresentationDriverKey(driverKey))) {
+ throw new ArgumentError(
+ '$driverKey is not a valid presentation driver key.');
+ }
+ return _presentationDriverPattern.firstMatch(driverKey).group(1);
+}
+
+// Returns true if a key is a presentation driver key.
+bool isPresentationDriverKey(String key) {
+ return _presentationDriverPattern.hasMatch(key);
+}
+
+// TODO(aghassemi): Don't use regex, just regular split should be fine.
+final RegExp _presentationQuestionPattern = new RegExp(
+ '($_uuidPattern)(?:/$_uuidPattern)(?:/questions/)($_uuidPattern)');
+String getPresentationQuestionKey(
+ String deckId, String presentationId, String questionId) {
+ return '$deckId/$presentationId/questions/$questionId';
+}
+
+String presentationQuestionKeyToDeckId(String key) {
+ if ((!isPresentationQuestionKey(key))) {
+ throw new ArgumentError('$key is not a valid presentation question key.');
+ }
+ return _presentationQuestionPattern.firstMatch(key).group(1);
+}
+
+String presentationQuestionKeyToQuestionId(String key) {
+ if ((!isPresentationQuestionKey(key))) {
+ throw new ArgumentError('$key is not a valid presentation question key.');
+ }
+ return _presentationQuestionPattern.firstMatch(key).group(2);
+}
+
+// Returns true if a key is a presentation question key.
+bool isPresentationQuestionKey(String key) {
+ return _presentationQuestionPattern.hasMatch(key);
}
diff --git a/dart/lib/styles/common.dart b/dart/lib/styles/common.dart
index 17962e8..01bd5ff 100644
--- a/dart/lib/styles/common.dart
+++ b/dart/lib/styles/common.dart
@@ -17,9 +17,9 @@
class Size {
static const double thumbnailWidth = 250.0;
- static const double listHeight = 150.0;
- static const double thumbnailNavHeight = 150.0;
- static const double thumbnailNavWidth = 267.0;
+ static const double listHeight = 120.0;
+ static const double thumbnailNavHeight = 250.0;
+ static const double questionListThumbnailWidth = 100.0;
}
class Spacing {
@@ -28,11 +28,18 @@
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 actionsMargin = new EdgeDims.only(right: 20.0);
+ static final EdgeDims actionsMargin =
+ new EdgeDims.symmetric(horizontal: 10.0);
static final EdgeDims fabMargin = new EdgeDims.only(right: 7.0);
+ static final EdgeDims footerVerticalMargin =
+ const EdgeDims.symmetric(vertical: 14.0);
+ static final EdgeDims footerHorizontalMargin =
+ const EdgeDims.symmetric(horizontal: 24.0);
}
class Box {
+ static final Color bubbleOverlayBackground = new Color.fromARGB(80, 0, 0, 0);
+ static final Color footerBackground = new Color(0xFF323232);
static final BoxDecoration liveNow = new BoxDecoration(
border: new Border.all(color: theme.accentColor), borderRadius: 2.0);
}
diff --git a/dart/lib/syncbase/client.dart b/dart/lib/syncbase/client.dart
index 9424099..dc95299 100644
--- a/dart/lib/syncbase/client.dart
+++ b/dart/lib/syncbase/client.dart
@@ -8,7 +8,6 @@
import 'package:logging/logging.dart';
import 'package:syncbase/syncbase_client.dart';
-import '../config.dart' as config;
import '../utils/errors.dart' as errorsutil;
export 'package:syncbase/syncbase_client.dart';
@@ -36,11 +35,12 @@
return _db;
}
-Future createSyncgroup(String syncgroupName, prefixes) async {
+Future createSyncgroup(
+ String mounttable, String syncgroupName, prefixes) async {
SyncbaseDatabase sbDb = await getDatabase();
SyncbaseSyncgroup sg = sbDb.syncgroup(syncgroupName);
var sgSpec = SyncbaseClient.syncgroupSpec(prefixes,
- perms: createOpenPerms(), mountTables: [config.mounttableAddr]);
+ perms: createOpenPerms(), mountTables: [mounttable]);
var myInfo = SyncbaseClient.syncgroupMemberInfo(syncPriority: 1);
await sg.create(sgSpec, myInfo);
diff --git a/dart/lib/utils/back_button.dart b/dart/lib/utils/back_button.dart
index 9df68c2..c1b9164 100644
--- a/dart/lib/utils/back_button.dart
+++ b/dart/lib/utils/back_button.dart
@@ -30,11 +30,10 @@
class _InputHandler extends InputClient {
dynamic onBackButton([Function responseFactory]) {
- bool exit = !_handler();
- if (exit) {
- return null;
- } else {
- return responseFactory();
- }
+ // TODO(aghassemi): Currently there is no way to tell mojo:input service
+ // to use the boolean returned by the handler to exit the app.
+ // See https://github.com/domokit/mojo/issues/544
+ _handler();
+ return responseFactory();
}
}
diff --git a/dart/pubspec.lock b/dart/pubspec.lock
index cfed1e8..f808f5e 100644
--- a/dart/pubspec.lock
+++ b/dart/pubspec.lock
@@ -218,7 +218,7 @@
test:
description: test
source: hosted
- version: "0.12.5+1"
+ version: "0.12.5+2"
typed_data:
description: typed_data
source: hosted