syncslides: Basic navigation functionality.
- Copy/paste the XML from the previous version.
- NavigateFragment is heavily based on the previous version,
but it still should be reviewed fully. There are many
parts that are commented out because I wasn't ready to
implement the corresponding syncbase interactions.
- Removed the Presentation interface. I'll just put it all
in Session.
mSession.getPresentation().foo()
vs
mSession.foo()
- Fixed a bug in WatchedList where the second and subsequent
listeners would not get added to the list.
Change-Id: I20bdda9dba465462941df435b0948cbfe47747ec
diff --git a/android/app/src/main/java/io/v/syncslides/NavigateFragment.java b/android/app/src/main/java/io/v/syncslides/NavigateFragment.java
new file mode 100644
index 0000000..7a2cb34
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/NavigateFragment.java
@@ -0,0 +1,424 @@
+// 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.
+
+package io.v.syncslides;
+
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import io.v.syncslides.db.DB;
+import io.v.syncslides.model.DynamicList;
+import io.v.syncslides.model.ListListener;
+import io.v.syncslides.model.Session;
+import io.v.syncslides.model.Slide;
+import io.v.v23.verror.VException;
+
+/**
+ * Provides both the presenter and audience views for navigating through a presentation.
+ * Instantiated by the PresentationActivity along with other views/fragments of the presentation
+ * to make transitions between them seamless.
+ */
+public class NavigateFragment extends Fragment {
+ private static final String TAG = "NavigateFragment";
+ private static final String SESSION_ID_KEY = "session_id_key";
+
+ private Session mSession;
+ private int mSlideNum = 0;
+ private SlideNumberListener mSlideNumberListener = new SlideNumberListener();
+ private ListListener mSlideListListener = new SlideListListener();
+ private ImageView mPrevThumb;
+ private ImageView mNextThumb;
+ private ImageView mCurrentSlide;
+ private TextView mSlideNumText;
+ private boolean mEditing;
+ private DynamicList<Slide> mSlides;
+
+ public static NavigateFragment newInstance(String sessionId) {
+ NavigateFragment fragment = new NavigateFragment();
+ Bundle args = new Bundle();
+ args.putString(SESSION_ID_KEY, sessionId);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ // When editing notes, display a menu with "Save".
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Bundle bundle = savedInstanceState;
+ if (bundle == null) {
+ bundle = getArguments();
+ }
+ String sessionId = bundle.getString(SESSION_ID_KEY);
+ try {
+ mSession = DB.Singleton.get().getSession(sessionId);
+ } catch (VException e) {
+ handleFatalError("Failed to fetch Session", e);
+ }
+
+ final View rootView = inflater.inflate(R.layout.fragment_navigate, container, false);
+
+// mFabSync = rootView.findViewById(R.id.audience_sync_fab);
+// if (((PresentationActivity) getActivity()).getSynced() || mRole != Role.AUDIENCE) {
+// mFabSync.setVisibility(View.INVISIBLE);
+// } else {
+// mFabSync.setVisibility(View.VISIBLE);
+// }
+//
+// mFabSync.setOnClickListener(new NavigateClickListener() {
+// @Override
+// public void onClick(View v) {
+// super.onClick(v);
+// sync();
+// mFabSync.setVisibility(View.INVISIBLE);
+// }
+// });
+ View.OnClickListener previousSlideListener = new NavigateClickListener() {
+ @Override
+ void onNavigate() {
+ previousSlide();
+ }
+ };
+ View arrowBack = rootView.findViewById(R.id.arrow_back);
+ arrowBack.setOnClickListener(previousSlideListener);
+ mPrevThumb = (ImageView) rootView.findViewById(R.id.prev_thumb);
+ mPrevThumb.setOnClickListener(previousSlideListener);
+
+ View.OnClickListener nextSlideListener = new NavigateClickListener() {
+ @Override
+ void onNavigate() {
+ nextSlide();
+ }
+ };
+ // Show either the arrowForward or the FAB but not both.
+ View arrowForward = rootView.findViewById(R.id.arrow_forward);
+ View fabForward = rootView.findViewById(R.id.primary_navigation_fab);
+// if (mRole == Role.PRESENTER) {
+// arrowForward.setVisibility(View.INVISIBLE);
+// fabForward.setOnClickListener(nextSlideListener);
+// } else {
+ fabForward.setVisibility(View.INVISIBLE);
+ arrowForward.setOnClickListener(nextSlideListener);
+// }
+ mNextThumb = (ImageView) rootView.findViewById(R.id.next_thumb);
+ mNextThumb.setOnClickListener(nextSlideListener);
+// mQuestions = (ImageView) rootView.findViewById(R.id.questions);
+// // TODO(kash): Hide the mQuestions button if mRole == BROWSER.
+// mQuestions.setOnClickListener(new NavigateClickListener() {
+// @Override
+// public void onClick(View v) {
+// super.onClick(v);
+// questionButton();
+// }
+// });
+ mCurrentSlide = (ImageView) rootView.findViewById(R.id.slide_current_medium);
+// mCurrentSlide.setOnClickListener(new NavigateClickListener() {
+// @Override
+// public void onClick(View v) {
+// super.onClick(v);
+// if (mRole == Role.AUDIENCE || mRole == Role.BROWSER) {
+// ((PresentationActivity) getActivity()).showFullscreenSlide(mSlideNum);
+// }
+// }
+// });
+//
+ mSlideNumText = (TextView) rootView.findViewById(R.id.slide_num_text);
+// mNotes = (EditText) rootView.findViewById(R.id.notes);
+// mNotes.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+// @Override
+// public void onFocusChange(View v, boolean hasFocus) {
+// ((PresentationActivity) getActivity()).getSupportActionBar().show();
+// mEditing = hasFocus;
+// getActivity().invalidateOptionsMenu();
+// unsync();
+// }
+// });
+//
+// // The parent of mNotes needs to be focusable in order to clear focus
+// // from mNotes when done editing. We set the attributes in code rather
+// // than in XML because it is too easy to add an extra level of layout
+// // in XML and forget to add these attributes.
+// ViewGroup parent = (ViewGroup) mNotes.getParent();
+// parent.setFocusable(true);
+// parent.setClickable(true);
+// parent.setFocusableInTouchMode(true);
+//
+// View slideListIcon = rootView.findViewById(R.id.slide_list);
+// slideListIcon.setOnClickListener(new NavigateClickListener() {
+// @Override
+// public void onClick(View v) {
+// super.onClick(v);
+// if (mRole == Role.AUDIENCE) {
+// ((PresentationActivity) getActivity()).showSlideList();
+// } else {
+// getActivity().getSupportFragmentManager().popBackStack();
+// }
+// }
+// });
+// mQuestionsNum = (TextView) rootView.findViewById(R.id.questions_num);
+// // Start off invisible for everyone. If there are questions, this
+// // will be set to visible in the mDB.getQuestionerList() callback.
+// mQuestionsNum.setVisibility(View.INVISIBLE);
+//
+// mDB = DB.Singleton.get(getActivity().getApplicationContext());
+// mDB.getSlides(mDeckId, new DB.Callback<List<Slide>>() {
+// @Override
+// public void done(List<Slide> slides) {
+// mSlides = slides;
+// // The CurrentSlideListener could have been notified while we were waiting for
+// // the slides to load.
+// if (mLoadingCurrentSlide != -1) {
+// currentSlideChanged(mLoadingCurrentSlide);
+// }
+// updateView();
+// }
+// });
+// if (((PresentationActivity) getActivity()).getSynced()) {
+// sync();
+// } else {
+// unsync();
+// }
+
+ return rootView;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ ((PresentationActivity) getActivity()).setUiImmersive(true);
+ mSlides = mSession.getSlides();
+ mSlides.addListener(mSlideListListener);
+ mSession.addSlideNumberListener(mSlideNumberListener);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ ((PresentationActivity) getActivity()).setUiImmersive(false);
+ mSession.removeSlideNumberListener(mSlideNumberListener);
+ mSlides.removeListener(mSlideListListener);
+ mSlides = null;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString(SESSION_ID_KEY, mSession.getId());
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ if (mEditing) {
+ inflater.inflate(R.menu.edit_notes, menu);
+ }
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.action_save:
+ saveNotes();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Advances to the next slide, if there is one, and updates the UI.
+ */
+ private void nextSlide() {
+ if (mSlideNum == -1) {
+ // Wait until the state has loaded before letting the user move around.
+ return;
+ }
+ if (mSlideNum < mSession.getSlides().getItemCount() - 1) {
+ try {
+ mSession.setLocalSlideNum(mSlideNum + 1);
+ } catch (VException e) {
+ handleError("Failed to advance", e);
+ }
+ }
+ }
+
+ /**
+ * Goes back to the previous slide, if there is one, and updates the UI.
+ */
+ private void previousSlide() {
+ if (mSlideNum == -1) {
+ // Wait until the state has loaded before letting the user move around.
+ return;
+ }
+ if (mSlideNum > 0) {
+ try {
+ mSession.setLocalSlideNum(mSlideNum - 1);
+ } catch (VException e) {
+ handleError("Failed to go back", e);
+ }
+ }
+ }
+
+ private void updateView() {
+ if (mSlideNum < 0 || mSlideNum >= mSlides.getItemCount()) {
+ // Still loading.
+ return;
+ }
+ if (mSlideNum > 0) {
+ setThumbBitmap(mPrevThumb, mSlides.get(mSlideNum - 1).getThumb());
+ } else {
+ setThumbNull(mPrevThumb);
+ }
+ // TODO(kash): Switch to full size image.
+ mCurrentSlide.setImageBitmap(mSlides.get(mSlideNum).getThumb());
+ if (mSlideNum == mSlides.getItemCount() - 1) {
+ setThumbNull(mNextThumb);
+ } else {
+ setThumbBitmap(mNextThumb, mSlides.get(mSlideNum + 1).getThumb());
+ }
+ // TODO(kash): Implement me.
+// if (!mSlides.get(mSlideNum).getNotes().equals("")) {
+// mNotes.setText(mSlides.get(mSlideNum).getNotes());
+// } else {
+// mNotes.getText().clear();
+// }
+ mSlideNumText.setText(
+ String.valueOf(mSlideNum + 1) + " of " + String.valueOf(mSlides.getItemCount()));
+ }
+
+ private void setThumbBitmap(ImageView thumb, Bitmap bitmap) {
+ thumb.setImageBitmap(bitmap);
+ // In landscape, the height is dependent on the image size. However, if the
+ // image was null, the height is hardcoded to 9/16 of the width in setThumbNull.
+ // This resets it to the actual image size.
+ if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ ViewGroup.LayoutParams thumbParams = thumb.getLayoutParams();
+ thumbParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
+ }
+ }
+
+ private void setThumbNull(ImageView thumb) {
+ thumb.setImageDrawable(null);
+ // In landscape, the height is dependent on the image size. Because we don't have an
+ // image, assume all of the images are 16:9. The width is fixed, so we can calculate
+ // the expected height.
+ if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ ViewGroup grandparent = (ViewGroup) thumb.getParent().getParent();
+ ViewGroup.LayoutParams thumbParams = thumb.getLayoutParams();
+ thumbParams.height = (int) ((9 / 16.0) * grandparent.getMeasuredWidth());
+ }
+ }
+
+ /**
+ * If the user is editing the text field and the text has changed, save the
+ * notes locally and in Syncbase.
+ */
+ public void saveNotes() {
+ // TODO(kash): Port this code.
+// final String notes = mNotes.getText().toString();
+// if (mEditing && (!notes.equals(mSlides.get(mUserSlideNum).getNotes()))) {
+// toast("Saving notes");
+// mSlides.get(mUserSlideNum).setNotes(notes);
+// mDB.setSlideNotes(mDeckId, mUserSlideNum, notes);
+// }
+// mNotes.clearFocus();
+// InputMethodManager inputManager =
+// (InputMethodManager) getContext().
+// getSystemService(Context.INPUT_METHOD_SERVICE);
+// if (getActivity().getCurrentFocus() != null) {
+// inputManager.hideSoftInputFromWindow(
+// getActivity().getCurrentFocus().getWindowToken(),
+// InputMethodManager.HIDE_NOT_ALWAYS);
+// }
+// ((PresentationActivity) getActivity()).setUiImmersive(true);
+ }
+
+ private void handleError(String msg, Throwable throwable) {
+ Log.e(TAG, msg + ": " + Log.getStackTraceString(throwable));
+ Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show();
+ }
+
+ private void handleFatalError(String msg, Throwable throwable) {
+ handleError(msg, throwable);
+ getActivity().finish();
+ }
+
+ /**
+ * Updates the view whenever the list of slides changes.
+ */
+ private class SlideListListener implements ListListener {
+ @Override
+ public void notifyDataSetChanged() {
+ updateView();
+ }
+
+ @Override
+ public void notifyItemChanged(int position) {
+ updateView();
+ }
+
+ @Override
+ public void notifyItemInserted(int position) {
+ updateView();
+ }
+
+ @Override
+ public void notifyItemRemoved(int position) {
+ updateView();
+ }
+
+ @Override
+ public void onError(Exception e) {
+ handleFatalError("Error watching slide list", e);
+ }
+ }
+
+ private class SlideNumberListener implements Session.SlideNumberListener {
+ @Override
+ public void onChange(int slideNum) {
+ mSlideNum = slideNum;
+ updateView();
+ }
+
+ @Override
+ public void onError(Exception e) {
+ handleFatalError("Error listening to slide number changes", e);
+ }
+ }
+
+ /**
+ * If the user is editing notes and then clicks anywhere else on the screen,
+ * we want that action to save the notes first. Using this class forces
+ * that behavior.
+ */
+ private abstract class NavigateClickListener implements View.OnClickListener {
+ @Override
+ public final void onClick(View v) {
+ saveNotes();
+ onNavigate();
+ }
+
+ /**
+ * Called when there is a click.
+ */
+ abstract void onNavigate();
+ }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/PresentationActivity.java b/android/app/src/main/java/io/v/syncslides/PresentationActivity.java
index b8bc34d..c56fe3e 100644
--- a/android/app/src/main/java/io/v/syncslides/PresentationActivity.java
+++ b/android/app/src/main/java/io/v/syncslides/PresentationActivity.java
@@ -6,6 +6,7 @@
import android.content.Intent;
import android.os.Bundle;
+import android.support.v4.app.FragmentTransaction;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
@@ -24,7 +25,6 @@
public static final String SESSION_ID_KEY = "session_id_key";
- private String mSessionId;
private Session mSession;
@Override
@@ -41,22 +41,24 @@
}
setContentView(R.layout.activity_presentation);
+ String sessionId;
if (savedInstanceState == null) {
- mSessionId = getIntent().getStringExtra(SESSION_ID_KEY);
+ sessionId = getIntent().getStringExtra(SESSION_ID_KEY);
} else {
- mSessionId = savedInstanceState.getString(SESSION_ID_KEY);
- }
- if (savedInstanceState != null) {
- // Let the framework take care of inflating the right fragment.
- return;
+ sessionId = savedInstanceState.getString(SESSION_ID_KEY);
}
DB db = DB.Singleton.get();
try {
- mSession = db.getSession(mSessionId);
+ mSession = db.getSession(sessionId);
} catch (VException e) {
handleError("Failed to load state", e);
finish();
}
+
+ if (savedInstanceState != null) {
+ // Let the framework take care of inflating the right fragment.
+ return;
+ }
showSlideList();
}
@@ -79,7 +81,7 @@
@Override
protected void onSaveInstanceState(Bundle b) {
super.onSaveInstanceState(b);
- b.putString(SESSION_ID_KEY, mSessionId);
+ b.putString(SESSION_ID_KEY, mSession.getId());
}
/**
@@ -111,12 +113,34 @@
* presenting.
*/
public void showSlideList() {
- SlideListFragment fragment = SlideListFragment.newInstance(mSessionId);
+ SlideListFragment fragment = SlideListFragment.newInstance(mSession.getId());
getSupportFragmentManager().beginTransaction().replace(R.id.fragment, fragment).commit();
}
+ /**
+ * Shows the navigate fragment where the user can see the given slide and
+ * navigate to other components of the slide presentation.
+ */
+ public void navigateToSlide(int slideNum) {
+ try {
+ mSession.setLocalSlideNum(slideNum);
+ } catch (VException e) {
+ handleError("Could not update session", e);
+ return;
+ }
+ NavigateFragment fragment = NavigateFragment.newInstance(mSession.getId());
+ FragmentTransaction transaction = getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.fragment, fragment);
+ // TODO(kash): Figure out if we need to do this.
+// if (mRole != Role.AUDIENCE) {
+// transaction.addToBackStack("");
+// }
+ transaction.commit();
+ }
+
private void handleError(String msg, Throwable throwable) {
Log.e(TAG, msg + ": " + Log.getStackTraceString(throwable));
Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/io/v/syncslides/SlideListAdapter.java b/android/app/src/main/java/io/v/syncslides/SlideListAdapter.java
index a0e01ca..2019fa6 100644
--- a/android/app/src/main/java/io/v/syncslides/SlideListAdapter.java
+++ b/android/app/src/main/java/io/v/syncslides/SlideListAdapter.java
@@ -26,9 +26,9 @@
private final RecyclerView mRecyclerView;
private DynamicList<Slide> mSlides;
- public SlideListAdapter(RecyclerView recyclerView, DB db, Session session) {
+ public SlideListAdapter(RecyclerView recyclerView, DB db, DynamicList<Slide> slides) {
mRecyclerView = recyclerView;
- mSlides = db.getPresentation(session).getSlides();
+ mSlides = slides;
mSlides.addListener(this);
}
@@ -40,8 +40,7 @@
@Override
public void onClick(View v) {
int position = mRecyclerView.getChildAdapterPosition(v);
- // TODO(kash): Implement me.
- //((PresentationActivity) v.getContext()).navigateToSlide(position);
+ ((PresentationActivity) v.getContext()).navigateToSlide(position);
}
});
return new ViewHolder(v);
diff --git a/android/app/src/main/java/io/v/syncslides/SlideListFragment.java b/android/app/src/main/java/io/v/syncslides/SlideListFragment.java
index 183df0d..577ec8b 100644
--- a/android/app/src/main/java/io/v/syncslides/SlideListFragment.java
+++ b/android/app/src/main/java/io/v/syncslides/SlideListFragment.java
@@ -95,7 +95,7 @@
public void onStart() {
super.onStart();
DB db = DB.Singleton.get();
- mAdapter = new SlideListAdapter(mRecyclerView, db, mSession);
+ mAdapter = new SlideListAdapter(mRecyclerView, db, mSession.getSlides());
mRecyclerView.setAdapter(mAdapter);
}
diff --git a/android/app/src/main/java/io/v/syncslides/db/DB.java b/android/app/src/main/java/io/v/syncslides/db/DB.java
index 2378d7f..f437455 100644
--- a/android/app/src/main/java/io/v/syncslides/db/DB.java
+++ b/android/app/src/main/java/io/v/syncslides/db/DB.java
@@ -60,11 +60,6 @@
Session getSession(String sessionId) throws VException;
/**
- * Returns the Presentation for the given session.
- */
- Presentation getPresentation(Session session);
-
- /**
* Returns a dynamically updating list of decks that are visible to the user.
*/
DynamicList<Deck> getDecks();
diff --git a/android/app/src/main/java/io/v/syncslides/db/Presentation.java b/android/app/src/main/java/io/v/syncslides/db/Presentation.java
deleted file mode 100644
index c0933fa..0000000
--- a/android/app/src/main/java/io/v/syncslides/db/Presentation.java
+++ /dev/null
@@ -1,20 +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.
-
-package io.v.syncslides.db;
-
-import io.v.syncslides.model.DynamicList;
-import io.v.syncslides.model.Slide;
-
-/**
- * A Presentation acts as a bridge between the UI and the database for all of the state related
- * to a presentation.
- */
-public interface Presentation {
-
- /**
- * Returns a dynamically updating list of slides in the deck.
- */
- DynamicList<Slide> getSlides();
-}
diff --git a/android/app/src/main/java/io/v/syncslides/db/SlideNumberWatcher.java b/android/app/src/main/java/io/v/syncslides/db/SlideNumberWatcher.java
new file mode 100644
index 0000000..4440179
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/db/SlideNumberWatcher.java
@@ -0,0 +1,223 @@
+// 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.
+
+package io.v.syncslides.db;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import com.google.common.collect.Sets;
+
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import io.v.impl.google.naming.NamingUtil;
+import io.v.syncslides.model.Session;
+import io.v.v23.VIterable;
+import io.v.v23.context.CancelableVContext;
+import io.v.v23.context.VContext;
+import io.v.v23.services.watch.ResumeMarker;
+import io.v.v23.syncbase.nosql.BatchDatabase;
+import io.v.v23.syncbase.nosql.ChangeType;
+import io.v.v23.syncbase.nosql.Database;
+import io.v.v23.syncbase.nosql.Table;
+import io.v.v23.syncbase.nosql.WatchChange;
+import io.v.v23.verror.VException;
+import io.v.v23.vom.VomUtil;
+
+import static io.v.v23.VFutures.sync;
+
+/**
+ * Watches both the local slide number as well as the live presentation's slide number.
+ * If the local number is INVALID_LOCAL_SLIDE_NUM, notifies listeners whenever the live
+ * presentation's slide number changes. Otherwise, it notifies listeners whenever the
+ * local number changes.
+ */
+class SlideNumberWatcher {
+ private static final String TAG = "SlideNumberWatcher";
+
+ private final VContext mBaseContext;
+ private final Database mDb;
+ private final Set<Session.SlideNumberListener> mListeners;
+ private final ExecutorService mExecutor;
+ private final Handler mHandler;
+ private final String mSessionId;
+ private final String mDeckId;
+ private final String mPresentationId;
+ private int mLocalSlideNum;
+ private VCurrentSlide mCurrentSlide;
+ private CancelableVContext mCurrentContext;
+
+ /**
+ * If presentationId is non-null, SlideNumberWatcher will watch for changes in addition
+ * to watching the session's local slide number.
+ */
+ SlideNumberWatcher(VContext context, Database db, String sessionId, String deckId,
+ String presentationId) {
+ mBaseContext = context;
+ mDb = db;
+ mSessionId = sessionId;
+ mDeckId = deckId;
+ mPresentationId = presentationId;
+ mListeners = Sets.newHashSet();
+ mLocalSlideNum = SyncbaseSession.INVALID_LOCAL_SLIDE_NUM;
+ mHandler = new Handler(Looper.getMainLooper());
+ mExecutor = Executors.newFixedThreadPool(2);
+ }
+
+ void addListener(Session.SlideNumberListener listener) {
+ mListeners.add(listener);
+ if (mListeners.size() == 1) {
+ // First listener. Start the threads.
+ mCurrentContext = mBaseContext.withCancel();
+ mExecutor.submit(new Runnable() {
+ @Override
+ public void run() {
+ watchLocalSlideNum();
+ }
+ });
+ mExecutor.submit(new Runnable() {
+ @Override
+ public void run() {
+ watchCurrentSlide();
+ }
+ });
+ }
+ listener.onChange(getSlideNum());
+ }
+
+ void removeListener(Session.SlideNumberListener listener) {
+ mListeners.remove(listener);
+ if (mListeners.isEmpty()) {
+ // Stop watchers via cancel.
+ mCurrentContext.cancel();
+ mCurrentContext = null;
+ mHandler.removeCallbacksAndMessages(null);
+ }
+ }
+
+ private void currentSlideChanged(VCurrentSlide slide) {
+ mCurrentSlide = slide;
+ notifyListeners();
+ }
+
+ private void localSlideChanged(int localSlide) {
+ mLocalSlideNum = localSlide;
+ notifyListeners();
+ }
+
+ private void notifyListeners() {
+ int slideNum = getSlideNum();
+ for (Session.SlideNumberListener listener : mListeners) {
+ listener.onChange(slideNum);
+ }
+ }
+
+ private int getSlideNum() {
+ int slideNum = mLocalSlideNum;
+ if (slideNum == SyncbaseSession.INVALID_LOCAL_SLIDE_NUM && mCurrentSlide != null) {
+ slideNum = mCurrentSlide.getSlideNum();
+ }
+ return slideNum;
+ }
+
+ private void notifyError(Exception e) {
+ for (Session.SlideNumberListener listener : mListeners) {
+ listener.onError(e);
+ }
+ }
+
+ // Runs in a background thread.
+ private void watchCurrentSlide() {
+ try {
+ Log.i(TAG, "watchCurrentSlide");
+ String rowKey = NamingUtil.join(mDeckId, mPresentationId, SyncbaseDB.CURRENT_SLIDE);
+ BatchDatabase batch = sync(mDb.beginBatch(mCurrentContext, null));
+ Table presentations = batch.getTable(SyncbaseDB.PRESENTATIONS_TABLE);
+ if (sync(presentations.getRow(rowKey).exists(mCurrentContext))) {
+ final VCurrentSlide slide = (VCurrentSlide) presentations.get(
+ mCurrentContext, rowKey, VCurrentSlide.class);
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ currentSlideChanged(slide);
+ }
+ });
+ }
+ ResumeMarker marker = sync(batch.getResumeMarker(mCurrentContext));
+ VIterable<WatchChange> changes =
+ sync(mDb.watch(mCurrentContext, SyncbaseDB.PRESENTATIONS_TABLE,
+ rowKey, marker));
+ for (WatchChange change : changes) {
+ String key = change.getRowName();
+ Log.i(TAG, "Found CurrentSlide change " + key);
+ if (!key.equals(rowKey)) {
+ continue;
+ }
+ if (change.getChangeType().equals(ChangeType.PUT_CHANGE)) {
+ final VCurrentSlide slide = (VCurrentSlide) VomUtil.decode(
+ change.getVomValue(), VCurrentSlide.class);
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ currentSlideChanged(slide);
+ }
+ });
+ }
+ }
+ } catch (final VException e) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ notifyError(e);
+ }
+ });
+ }
+ }
+
+ // Runs in a background thread.
+ private void watchLocalSlideNum() {
+ try {
+ BatchDatabase batch = sync(mDb.beginBatch(mCurrentContext, null));
+ Table ui = batch.getTable(SyncbaseDB.UI_TABLE);
+ final VSession vSession = (VSession) sync(ui.get(
+ mCurrentContext, mSessionId, VSession.class));
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ localSlideChanged(vSession.getLocalSlide());
+ }
+ });
+ ResumeMarker marker = sync(batch.getResumeMarker(mCurrentContext));
+ VIterable<WatchChange> changes =
+ sync(mDb.watch(mCurrentContext, SyncbaseDB.UI_TABLE, mSessionId, marker));
+ for (WatchChange change : changes) {
+ String key = change.getRowName();
+ Log.i(TAG, "Found local slide change " + key);
+ if (!key.equals(mSessionId)) {
+ continue;
+ }
+ if (change.getChangeType().equals(ChangeType.PUT_CHANGE)) {
+ final VSession vSession1 = (VSession) VomUtil.decode(
+ change.getVomValue(), VSession.class);
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ localSlideChanged(vSession1.getLocalSlide());
+ }
+ });
+ }
+ }
+ } catch (final VException e) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ notifyError(e);
+ }
+ });
+ }
+ }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java b/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
index e7a11d8..99306fb 100644
--- a/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
+++ b/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
@@ -171,11 +171,9 @@
@Override
public String createSession(String deckId) throws VException {
- Table ui = mDB.getTable(UI_TABLE);
- CancelableVContext context = mVContext.withTimeout(Duration.millis(5000));
- VSession vSession = new VSession(deckId, null, -1, System.currentTimeMillis());
String uuid = UUID.randomUUID().toString();
- sync(ui.put(context, uuid, vSession, VSession.class));
+ SyncbaseSession session = new SyncbaseSession(mVContext, mDB, uuid, deckId);
+ session.save();
return uuid;
}
@@ -184,14 +182,7 @@
Table ui = mDB.getTable(UI_TABLE);
CancelableVContext context = mVContext.withTimeout(Duration.millis(5000));
VSession vSession = (VSession) sync(ui.get(context, sessionId, VSession.class));
- return new SyncbaseSession(sessionId, vSession);
- }
-
- @Override
- public Presentation getPresentation(Session session) {
- // TODO(kash): Cache this presentation so that it survives the phone
- // rotating.
- return new SyncbasePresentation(mVContext, mDB, session);
+ return new SyncbaseSession(mVContext, mDB, sessionId, vSession);
}
@Override
diff --git a/android/app/src/main/java/io/v/syncslides/db/SyncbasePresentation.java b/android/app/src/main/java/io/v/syncslides/db/SyncbasePresentation.java
deleted file mode 100644
index c1363fd..0000000
--- a/android/app/src/main/java/io/v/syncslides/db/SyncbasePresentation.java
+++ /dev/null
@@ -1,31 +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.
-
-package io.v.syncslides.db;
-
-import io.v.syncslides.model.DynamicList;
-import io.v.syncslides.model.Session;
-import io.v.syncslides.model.Slide;
-import io.v.v23.context.VContext;
-import io.v.v23.syncbase.nosql.Database;
-
-class SyncbasePresentation implements Presentation {
- private final VContext mVContext;
- private final Database mDb;
- private final Session mSession;
-
- public SyncbasePresentation(VContext vContext, Database db, Session session) {
- mVContext = vContext;
- mDb = db;
- mSession = session;
- }
-
- @Override
- public DynamicList<Slide> getSlides() {
- // TODO(kash): Cache this list so it survives a phone rotation.
- // We'll need a corresponding method to clear the cache when this
- // Presentation is no longer needed.
- return new WatchedList<>(mVContext, new SlideWatcher(mDb, mSession.getDeckId()));
- }
-}
diff --git a/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java b/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java
index c70a6af..2ac8865 100644
--- a/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java
+++ b/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java
@@ -4,18 +4,49 @@
package io.v.syncslides.db;
+import org.joda.time.Duration;
+
+import io.v.syncslides.model.DynamicList;
import io.v.syncslides.model.Session;
+import io.v.syncslides.model.Slide;
+import io.v.v23.context.CancelableVContext;
+import io.v.v23.context.VContext;
+import io.v.v23.syncbase.nosql.Database;
+import io.v.v23.syncbase.nosql.Table;
+import io.v.v23.verror.VException;
+
+import static io.v.v23.VFutures.sync;
/**
* SyncbaseSession gets its session state from Syncbase.
*/
class SyncbaseSession implements Session {
- private String mId;
- private final VSession mVSession;
+ /**
+ * The user wants to follow a live presentation or hasn't yet chosen a slide to view.
+ */
+ static final int INVALID_LOCAL_SLIDE_NUM = -1;
+ private static final long UNINITIALIZED_TIME = 0;
- SyncbaseSession(String id, VSession vSession) {
+ private final VContext mVContext;
+ private final Database mDb;
+ private final String mId;
+ private final VSession mVSession;
+ private final SlideNumberWatcher mSlideNumberWatcher;
+ private final DynamicList<Slide> mSlides;
+
+ SyncbaseSession(VContext vContext, Database db, String id, String deckId) {
+ this(vContext, db, id,
+ new VSession(deckId, null, INVALID_LOCAL_SLIDE_NUM, UNINITIALIZED_TIME));
+ }
+
+ SyncbaseSession(VContext vContext, Database db, String id, VSession vSession) {
+ mVContext = vContext;
+ mDb = db;
mId = id;
mVSession = vSession;
+ mSlideNumberWatcher = new SlideNumberWatcher(mVContext, mDb, id, mVSession.getDeckId(),
+ mVSession.getPresentationId());
+ mSlides = new WatchedList<>(mVContext, new SlideWatcher(mDb, mVSession.getDeckId()));
}
@Override
@@ -32,4 +63,38 @@
public String getPresentationId() {
return mVSession.getPresentationId();
}
+
+ @Override
+ public void setLocalSlideNum(int slideNum) throws VException {
+ // TODO(kash): if the user is driving, this should update the presentation state instead.
+ mVSession.setLocalSlide(slideNum);
+ save();
+ }
+
+ @Override
+ public void addSlideNumberListener(SlideNumberListener listener) {
+ mSlideNumberWatcher.addListener(listener);
+ }
+
+ @Override
+ public void removeSlideNumberListener(SlideNumberListener listener) {
+ mSlideNumberWatcher.removeListener(listener);
+ }
+
+ @Override
+ public DynamicList<Slide> getSlides() {
+ return mSlides;
+ }
+
+ /**
+ * Persists the VSession to the UI_TABLE.
+ *
+ * @throws VException when the Syncbase put() fails
+ */
+ void save() throws VException {
+ mVSession.setLastTouched(System.currentTimeMillis());
+ Table ui = mDb.getTable(SyncbaseDB.UI_TABLE);
+ CancelableVContext context = mVContext.withTimeout(Duration.millis(5000));
+ sync(ui.put(context, mId, mVSession, VSession.class));
+ }
}
diff --git a/android/app/src/main/java/io/v/syncslides/db/WatchedList.java b/android/app/src/main/java/io/v/syncslides/db/WatchedList.java
index cdb367b..4ab9cd1 100644
--- a/android/app/src/main/java/io/v/syncslides/db/WatchedList.java
+++ b/android/app/src/main/java/io/v/syncslides/db/WatchedList.java
@@ -59,8 +59,9 @@
@Override
public void addListener(final ListListener listener) {
- if (mListeners.isEmpty()) {
- mListeners.add(listener);
+ mListeners.add(listener);
+ if (mListeners.size() == 1) {
+ // First listener. Start the thread.
mCurrentContext = mBaseContext.withCancel();
mExecutor.submit(new Runnable() {
@Override
diff --git a/android/app/src/main/java/io/v/syncslides/model/Session.java b/android/app/src/main/java/io/v/syncslides/model/Session.java
index a674964..0c6a877 100644
--- a/android/app/src/main/java/io/v/syncslides/model/Session.java
+++ b/android/app/src/main/java/io/v/syncslides/model/Session.java
@@ -4,11 +4,58 @@
package io.v.syncslides.model;
+import io.v.v23.verror.VException;
+
/**
* A Session represents the UI state for one presentation.
*/
public interface Session {
String getId();
+
String getDeckId();
+
String getPresentationId();
+
+ /**
+ * Store the user's desire to view the given slide. This will trigger a notification to
+ * any SlideNumberListeners.
+ *
+ * @param slideNum the number of the slide
+ */
+ void setLocalSlideNum(int slideNum) throws VException;
+
+ interface SlideNumberListener {
+ /**
+ * Called whenever the UI should display a different slide. If the user is following
+ * a live presentation, this will be called when the driver of the presentation changes
+ * the current slide. If the user is browsing a presentation on his own, this will
+ * be triggered by calls to setLocalSlideNum().
+ */
+ void onChange(int slideNum);
+ /**
+ * Called whenever there is an error. The listener should unregister and re-register
+ * itself if it wants to continue.
+ */
+ void onError(Exception e);
+ }
+
+ /**
+ * Adds a listener for changes to the to-be-displayed slide.
+ *
+ * @param listener notified of changes
+ */
+ void addSlideNumberListener(SlideNumberListener listener);
+
+ /**
+ * Removes a listener that was previously passed to addSlideNumberListener().
+ *
+ * @param listener previously passed to addSlideNumberListener().
+ */
+ void removeSlideNumberListener(SlideNumberListener listener);
+
+ /**
+ * Returns a dynamically updating list of slides in the deck.
+ */
+ DynamicList<Slide> getSlides();
+
}
diff --git a/android/app/src/main/res/layout-land/fragment_navigate.xml b/android/app/src/main/res/layout-land/fragment_navigate.xml
new file mode 100644
index 0000000..41119a1
--- /dev/null
+++ b/android/app/src/main/res/layout-land/fragment_navigate.xml
@@ -0,0 +1,216 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/navigate_fragment"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="horizontal">
+
+ <LinearLayout
+ android:id="@+id/notes_and_thumbs"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <!-- Editable notes for the current slide. -->
+ <EditText
+ android:id="@+id/notes"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:background="@color/blue_grey_50"
+ android:gravity="left"
+ android:hint="@string/notes_hint"
+ android:textSize="18sp" />
+
+ <RelativeLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+
+ <ImageView
+ android:id="@+id/next_thumb"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true"
+ android:adjustViewBounds="true"
+ android:background="@color/blue_grey_50"
+ android:scaleType="fitCenter" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:layout_centerHorizontal="true"
+ android:layout_margin="@dimen/nav_hint_margin"
+ android:background="@drawable/nav_hint"
+ android:paddingLeft="@dimen/nav_hint_padding"
+ android:paddingRight="@dimen/nav_hint_padding"
+ android:text="@string/next_hint"
+ android:textColor="@color/nav_hint_text" />
+
+ </RelativeLayout>
+
+ <RelativeLayout
+ android:id="@+id/prev_thumb_layout"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+
+ <ImageView
+ android:id="@+id/prev_thumb"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true"
+ android:adjustViewBounds="true"
+ android:background="@color/blue_grey_50"
+ android:scaleType="fitCenter" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:layout_centerHorizontal="true"
+ android:layout_margin="@dimen/nav_hint_margin"
+ android:background="@drawable/nav_hint"
+ android:paddingLeft="@dimen/nav_hint_padding"
+ android:paddingRight="@dimen/nav_hint_padding"
+ android:text="@string/prev_hint"
+ android:textColor="@color/nav_hint_text" />
+
+ </RelativeLayout>
+
+ </LinearLayout>
+
+ <android.support.design.widget.CoordinatorLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/main_slide"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="3"
+ android:background="@color/matte_black">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1">
+
+ <!-- A medium version of the slide image. -->
+ <ImageView
+ android:id="@+id/slide_current_medium"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="center"
+ android:layout_margin="12dp"
+ android:adjustViewBounds="true"
+ android:scaleType="fitCenter" />
+
+ <TextView
+ android:id="@+id/slide_num_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBottom="@id/slide_current_medium"
+ android:layout_centerHorizontal="true"
+ android:layout_margin="@dimen/nav_hint_margin"
+ android:background="@drawable/nav_hint"
+ android:paddingLeft="@dimen/nav_hint_padding"
+ android:paddingRight="@dimen/nav_hint_padding"
+ android:textColor="@color/nav_hint_text" />
+
+ </RelativeLayout>
+
+ <!-- A bar containing icons to navigate the presentation. -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/nav_bar_height"
+ android:background="@color/toolbar_gray"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+ <!-- The icons below are 24dp scaled to 36dp. For whatever reason, the
+ 36 dp icons look too fat. -->
+ <ImageView
+ android:id="@+id/arrow_back"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginLeft="@dimen/nav_button_margin"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_arrow_back_white_24dp" />
+
+ <ImageView
+ android:id="@+id/slide_list"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_layers_white_24dp" />
+
+ <RelativeLayout
+ android:id="@+id/questions_layout"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+
+ <ImageView
+ android:id="@+id/questions"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_live_help_white_24dp" />
+
+ <TextView
+ android:id="@+id/questions_num"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignRight="@id/questions"
+ android:layout_alignTop="@id/questions"
+ android:background="@drawable/orange_circle"
+ android:gravity="center"
+ android:textColor="@color/nav_question_num_text"
+ android:textSize="@dimen/nav_question_num_size" />
+ </RelativeLayout>
+
+ <!-- This button shows up only some of the time. NavigateFragment makes it visible
+ dynamically. -->
+ <ImageView
+ android:id="@+id/arrow_forward"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_arrow_forward_white_24dp"
+ android:visibility="visible" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- This button shows up only some of the time. NavigateFragment makes it visible
+ dynamically. -->
+ <android.support.design.widget.FloatingActionButton
+ android:id="@+id/primary_navigation_fab"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="@dimen/fab_margin"
+ android:clickable="true"
+ android:src="@drawable/ic_arrow_forward_white_36dp"
+ android:visibility="visible"
+ app:backgroundTint="@color/action_orange"
+ app:layout_anchor="@id/slide_current_medium"
+ app:layout_anchorGravity="bottom|right|end" />
+
+ <!-- This button shows up only some of the time. NavigateFragment makes it visible
+ dynamically. -->
+ <android.support.design.widget.FloatingActionButton
+ android:id="@+id/audience_sync_fab"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="@dimen/fab_margin"
+ android:clickable="true"
+ android:src="@drawable/ic_sync_white_36dp"
+ android:visibility="invisible"
+ app:backgroundTint="@color/action_orange"
+ app:layout_anchor="@id/slide_current_medium"
+ app:layout_anchorGravity="bottom|right|end" />
+ </android.support.design.widget.CoordinatorLayout>
+
+</LinearLayout>
diff --git a/android/app/src/main/res/layout/fragment_navigate.xml b/android/app/src/main/res/layout/fragment_navigate.xml
new file mode 100644
index 0000000..a4d2556
--- /dev/null
+++ b/android/app/src/main/res/layout/fragment_navigate.xml
@@ -0,0 +1,216 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/navigate_fragment"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <android.support.design.widget.CoordinatorLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@color/snow_2">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <!-- A medium version of the slide image. -->
+ <ImageView
+ android:id="@+id/slide_current_medium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:adjustViewBounds="true"
+ android:scaleType="fitCenter" />
+
+ <TextView
+ android:id="@+id/slide_num_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBottom="@id/slide_current_medium"
+ android:layout_centerHorizontal="true"
+ android:layout_margin="@dimen/nav_hint_margin"
+ android:background="@drawable/nav_hint"
+ android:paddingLeft="@dimen/nav_hint_padding"
+ android:paddingRight="@dimen/nav_hint_padding"
+ android:textColor="@color/nav_hint_text" />
+
+ </RelativeLayout>
+
+ <!-- A bar containing icons to navigate the presentation. -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/nav_bar_height"
+ android:background="@color/toolbar_gray"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+ <!-- The icons below are 24dp scaled to 36dp. For whatever reason, the
+ 36 dp icons look too fat. -->
+ <ImageView
+ android:id="@+id/arrow_back"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginLeft="@dimen/nav_button_margin"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_arrow_back_white_24dp" />
+
+ <ImageView
+ android:id="@+id/slide_list"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_layers_white_24dp" />
+
+ <RelativeLayout
+ android:id="@+id/questions_layout"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+
+ <ImageView
+ android:id="@+id/questions"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_live_help_white_24dp" />
+
+ <TextView
+ android:id="@+id/questions_num"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignRight="@id/questions"
+ android:layout_alignTop="@id/questions"
+ android:background="@drawable/orange_circle"
+ android:gravity="center"
+ android:textColor="@color/nav_question_num_text"
+ android:textSize="@dimen/nav_question_num_size" />
+ </RelativeLayout>
+
+
+ <!-- This button shows up only some of the time. NavigateFragment makes it visible
+ dynamically. -->
+ <ImageView
+ android:id="@+id/arrow_forward"
+ android:layout_width="@dimen/nav_button_size"
+ android:layout_height="@dimen/nav_button_size"
+ android:layout_marginRight="@dimen/nav_button_margin"
+ android:src="@drawable/ic_arrow_forward_white_24dp"
+ android:visibility="visible" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- This button shows up only some of the time. NavigateFragment makes it visible
+ dynamically. -->
+ <android.support.design.widget.FloatingActionButton
+ android:id="@+id/primary_navigation_fab"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="@dimen/fab_margin"
+ android:clickable="true"
+ android:src="@drawable/ic_arrow_forward_white_36dp"
+ android:visibility="visible"
+ app:backgroundTint="@color/action_orange"
+ app:layout_anchor="@id/slide_current_medium"
+ app:layout_anchorGravity="bottom|right|end" />
+
+ <!-- This button shows up only some of the time. NavigateFragment makes it visible
+ dynamically. -->
+ <android.support.design.widget.FloatingActionButton
+ android:id="@+id/audience_sync_fab"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="@dimen/fab_margin"
+ android:clickable="true"
+ android:src="@drawable/ic_sync_white_36dp"
+ android:visibility="invisible"
+ app:backgroundTint="@color/action_orange"
+ app:layout_anchor="@id/slide_current_medium"
+ app:layout_anchorGravity="bottom|right|end" />
+ </android.support.design.widget.CoordinatorLayout>
+
+ <!-- Editable notes for the current slide. -->
+ <EditText
+ android:id="@+id/notes"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:background="@color/blue_grey_50"
+ android:gravity="left"
+ android:hint="@string/notes_hint"
+ android:inputType="textMultiLine|textAutoComplete|textCapSentences"
+ android:scrollbars="vertical"
+ android:textSize="18sp" />
+
+ <!-- Display the next and previous slide thumbnails. -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+ <!-- We want the two RelativeLayouts below to equally share the width of the screen.
+ Setting the width to 0dp and the weight to 1 does the right thing. -->
+ <RelativeLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1">
+
+ <ImageView
+ android:id="@+id/prev_thumb"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true"
+ android:adjustViewBounds="true"
+ android:background="@color/blue_grey_50"
+ android:scaleType="fitCenter" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentTop="true"
+ android:layout_margin="@dimen/nav_hint_margin"
+ android:background="@drawable/nav_hint"
+ android:paddingLeft="@dimen/nav_hint_padding"
+ android:paddingRight="@dimen/nav_hint_padding"
+ android:text="@string/prev_hint"
+ android:textColor="@color/nav_hint_text" />
+
+ </RelativeLayout>
+
+ <RelativeLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1">
+
+ <ImageView
+ android:id="@+id/next_thumb"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true"
+ android:adjustViewBounds="true"
+ android:background="@color/blue_grey_50"
+ android:scaleType="fitCenter" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentRight="true"
+ android:layout_alignParentTop="true"
+ android:layout_margin="@dimen/nav_hint_margin"
+ android:background="@drawable/nav_hint"
+ android:paddingLeft="@dimen/nav_hint_padding"
+ android:paddingRight="@dimen/nav_hint_padding"
+ android:text="@string/next_hint"
+ android:textColor="@color/nav_hint_text" />
+
+ </RelativeLayout>
+
+ </LinearLayout>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/android/app/src/main/res/menu/edit_notes.xml b/android/app/src/main/res/menu/edit_notes.xml
new file mode 100644
index 0000000..0c07f98
--- /dev/null
+++ b/android/app/src/main/res/menu/edit_notes.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <item
+ android:id="@+id/action_save"
+ android:title="@string/action_save"
+ app:showAsAction="always"/>
+
+</menu>
\ No newline at end of file