Implemented stopwatch page

This commit is contained in:
Phillip Hsu 2016-08-12 20:58:01 -07:00
parent 3e542f585e
commit 7dfda796f3
24 changed files with 1458 additions and 34 deletions

View File

@ -53,10 +53,23 @@ public abstract class AsyncDatabaseTableUpdateHandler<
}.execute(); }.execute();
} }
public final void asyncClear() {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
mTableManager.clear();
return null;
}
}.execute();
}
protected final Context getContext() { protected final Context getContext() {
return mAppContext; return mAppContext;
} }
// TODO: Consider giving a base impl that returns our mTableManager field.
// Subclasses will check if this base impl is null before creating and returning
// a new instance of the TableManager.
protected abstract TM getTableManager(Context context); protected abstract TM getTableManager(Context context);
protected abstract void onPostAsyncDelete(Integer result, T item); protected abstract void onPostAsyncDelete(Integer result, T item);

View File

@ -2,6 +2,7 @@ package com.philliphsu.clock2;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.support.design.widget.FloatingActionButton; import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.TabLayout; import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment; import android.support.v4.app.Fragment;
@ -16,6 +17,7 @@ import android.widget.TextView;
import com.philliphsu.clock2.alarms.AlarmsFragment; import com.philliphsu.clock2.alarms.AlarmsFragment;
import com.philliphsu.clock2.settings.SettingsActivity; import com.philliphsu.clock2.settings.SettingsActivity;
import com.philliphsu.clock2.stopwatch.StopwatchFragment;
import com.philliphsu.clock2.timers.TimersFragment; import com.philliphsu.clock2.timers.TimersFragment;
import butterknife.Bind; import butterknife.Bind;
@ -33,6 +35,13 @@ public class MainActivity extends BaseActivity {
*/ */
private SectionsPagerAdapter mSectionsPagerAdapter; private SectionsPagerAdapter mSectionsPagerAdapter;
// For delaying fab.show() on SCROLL_STATE_SETTLING
private final Handler mHandler = new Handler();
private boolean mScrollStateDragging;
private int mPageDragging = -1; // TOneverDO: initial value >= 0
private boolean mDraggingPastEndBoundaries;
@Bind(R.id.container) @Bind(R.id.container)
ViewPager mViewPager; ViewPager mViewPager;
@ -47,6 +56,76 @@ public class MainActivity extends BaseActivity {
// primary sections of the activity. // primary sections of the activity.
mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager());
mViewPager.setAdapter(mSectionsPagerAdapter); mViewPager.setAdapter(mSectionsPagerAdapter);
mViewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {
if (position == mSectionsPagerAdapter.getCount() - 1) {
mFab.hide();
} else {
mFab.show();
}
}
// @Override
// public void onPageScrollStateChanged(int state) {
// // TODO: This was not sufficient to prevent the user from quickly
// // hitting the fab for the previous page.
// switch (state) {
// case ViewPager.SCROLL_STATE_DRAGGING:
// if (mDraggingPastEndBoundaries) {
// return;
// }
// mScrollStateDragging = true;
// mPageDragging = mViewPager.getCurrentItem();
// mFab.hide();
// break;
// case ViewPager.SCROLL_STATE_SETTLING:
// if (!mScrollStateDragging) {
// mFab.hide();
// }
// mScrollStateDragging = false;
// // getCurrentItem() has changed to the target page we're settling on.
// // 200ms is the same as show/hide animation duration
// int targetPage = mViewPager.getCurrentItem();
// if (targetPage != 2) { // TODO: Use page constant
// int delay = mPageDragging == targetPage ? 0 : 200;
// mHandler.postDelayed(new Runnable() {
// @Override
// public void run() {
// mFab.show();
// }
// }, delay);
// }
// mPageDragging = -1;
// break;
// case ViewPager.SCROLL_STATE_IDLE:
// // Nothing
// break;
// }
// }
});
// mViewPager.setPageTransformer(false, new ViewPager.PageTransformer() {
// @Override
// public void transformPage(View page, float position) {
// Log.d(TAG, "position: " + position);
// // position represents a page's offset from the front-and-center position of 0 (the page
// // that is in full view). Consider pages A, B, C, D.
// // If we are now on page A (position 0), then pages B, C, and D are respectively
// // in positions 1, 2, 3.
// // If we move to the right to page B (now in position 0), then pages A, C, D are
// // respectively in positions -1, 1, 2.
// int currentPage = mViewPager.getCurrentItem();
// // TODO: Use page constants
// // Page 0 can't move one full page position to the right (i.e. there is no page to
// // the left of page 0 that can adopt the front-and-center position of 0 while page 0
// // moves to adopt position 1)
// mDraggingPastEndBoundaries = currentPage == 0 && position >= 0f
// // The last page can't move one full page position to the left (i.e. there
// // is no page to the right of the last page that can adopt the front-and-center
// // position of 0 while the last page moves to adopt position -1)
// || currentPage == mSectionsPagerAdapter.getCount() - 1 && position <= 0f;
// Log.d(TAG, "Draggin past end bounds: " + mDraggingPastEndBoundaries);
// }
// });
TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs); TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs);
tabLayout.setupWithViewPager(mViewPager); tabLayout.setupWithViewPager(mViewPager);
@ -62,6 +141,12 @@ public class MainActivity extends BaseActivity {
// // Fragment's onActivityResult() will NOT be called. // // Fragment's onActivityResult() will NOT be called.
// mSectionsPagerAdapter.getCurrentFragment() // mSectionsPagerAdapter.getCurrentFragment()
// .startActivityForResult(intent, AlarmsFragment.REQUEST_CREATE_ALARM); // .startActivityForResult(intent, AlarmsFragment.REQUEST_CREATE_ALARM);
// TODO: If the user switches between pages and is quick enough to hit the
// fab before the target page comes fully into view, the onFabClick()
// implementation of the previous page will be called. Perhaps do something
// with the current page from the ViewPager instead. E.g. monolithically handle
// each Fragment's fab click reaction in this activity.
Fragment f; Fragment f;
if ((f = mSectionsPagerAdapter.getCurrentFragment()) instanceof RecyclerViewFragment) { if ((f = mSectionsPagerAdapter.getCurrentFragment()) instanceof RecyclerViewFragment) {
((RecyclerViewFragment) f).onFabClick(); ((RecyclerViewFragment) f).onFabClick();
@ -156,6 +241,8 @@ public class MainActivity extends BaseActivity {
return AlarmsFragment.newInstance(1); return AlarmsFragment.newInstance(1);
case 1: case 1:
return new TimersFragment(); return new TimersFragment();
case 2:
return new StopwatchFragment();
default: default:
return PlaceholderFragment.newInstance(position + 1); return PlaceholderFragment.newInstance(position + 1);
} }

View File

@ -64,12 +64,6 @@ public abstract class RecyclerViewFragment<
return new LinearLayoutManager(getActivity()); return new LinearLayoutManager(getActivity());
} }
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLoaderManager().initLoader(0, null, this);
}
@Nullable @Nullable
@Override @Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
@ -79,6 +73,14 @@ public abstract class RecyclerViewFragment<
return view; return view;
} }
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
// http://stackoverflow.com/a/14632434/5055032
// A Loader's lifecycle is bound to its Activity, not its Fragment.
getLoaderManager().initLoader(0, null, this);
}
@Override @Override
public void onLoadFinished(Loader<C> loader, C data) { public void onLoadFinished(Loader<C> loader, C data) {
mAdapter.swapCursor(data); mAdapter.swapCursor(data);

View File

@ -64,6 +64,17 @@ public abstract class Timer extends ObjectWithId implements Parcelable {
public long timeRemaining() { public long timeRemaining() {
if (!hasStarted()) if (!hasStarted())
// TODO: Consider returning duration instead? So we can simplify
// bindChronometer() in TimerVH to:
// if (isRunning())
// ...
// else
// chronom.setDuration(timeRemaining())
// ---
// Actually, I think we can also simplify it even further to just:
// chronom.setDuration(timeRemaining())
// if (isRunning)
// chronom.start();
return 0; return 0;
return isRunning() return isRunning()
? endTime - SystemClock.elapsedRealtime() ? endTime - SystemClock.elapsedRealtime()

View File

@ -68,7 +68,12 @@ public final class AlarmsTable {
public static void onCreate(SQLiteDatabase db) { public static void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE " + TABLE_ALARMS + " (" db.execSQL("CREATE TABLE " + TABLE_ALARMS + " ("
+ COLUMN_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " // https://sqlite.org/autoinc.html
// If the AUTOINCREMENT keyword appears after INTEGER PRIMARY KEY, that changes the
// automatic ROWID assignment algorithm to prevent the reuse of ROWIDs over the
// lifetime of the database. In other words, the purpose of AUTOINCREMENT is to
// prevent the reuse of ROWIDs from previously deleted rows.
+ COLUMN_ID + " INTEGER PRIMARY KEY, "
+ COLUMN_HOUR + " INTEGER NOT NULL, " + COLUMN_HOUR + " INTEGER NOT NULL, "
+ COLUMN_MINUTES + " INTEGER NOT NULL, " + COLUMN_MINUTES + " INTEGER NOT NULL, "
+ COLUMN_LABEL + " TEXT, " + COLUMN_LABEL + " TEXT, "

View File

@ -4,6 +4,8 @@ import android.content.Context;
import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteOpenHelper;
import com.philliphsu.clock2.stopwatch.LapsTable;
/** /**
* Created by Phillip Hsu on 7/30/2016. * Created by Phillip Hsu on 7/30/2016.
*/ */
@ -31,11 +33,13 @@ public class ClockAppDatabaseHelper extends SQLiteOpenHelper {
public void onCreate(SQLiteDatabase db) { public void onCreate(SQLiteDatabase db) {
AlarmsTable.onCreate(db); AlarmsTable.onCreate(db);
TimersTable.onCreate(db); TimersTable.onCreate(db);
LapsTable.onCreate(db);
} }
@Override @Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
AlarmsTable.onUpgrade(db, oldVersion, newVersion); AlarmsTable.onUpgrade(db, oldVersion, newVersion);
TimersTable.onUpgrade(db, oldVersion, newVersion); TimersTable.onUpgrade(db, oldVersion, newVersion);
LapsTable.onUpgrade(db, oldVersion, newVersion);
} }
} }

View File

@ -117,6 +117,14 @@ public abstract class DatabaseTableManager<T extends ObjectWithId> {
limit); // limit limit); // limit
} }
/**
* Deletes all rows in this table.
*/
public final void clear() {
mDbHelper.getWritableDatabase().delete(getTableName(), null/*all rows*/, null);
notifyContentChanged();
}
private void notifyContentChanged() { private void notifyContentChanged() {
LocalBroadcastHelper.sendBroadcast(mAppContext, getOnContentChangeAction()); LocalBroadcastHelper.sendBroadcast(mAppContext, getOnContentChangeAction());
} }

View File

@ -37,7 +37,12 @@ public final class TimersTable {
public static void onCreate(SQLiteDatabase db) { public static void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE " + TABLE_TIMERS + " (" db.execSQL("CREATE TABLE " + TABLE_TIMERS + " ("
+ COLUMN_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " // https://sqlite.org/autoinc.html
// If the AUTOINCREMENT keyword appears after INTEGER PRIMARY KEY, that changes the
// automatic ROWID assignment algorithm to prevent the reuse of ROWIDs over the
// lifetime of the database. In other words, the purpose of AUTOINCREMENT is to
// prevent the reuse of ROWIDs from previously deleted rows.
+ COLUMN_ID + " INTEGER PRIMARY KEY, "
+ COLUMN_HOUR + " INTEGER NOT NULL, " + COLUMN_HOUR + " INTEGER NOT NULL, "
+ COLUMN_MINUTE + " INTEGER NOT NULL, " + COLUMN_MINUTE + " INTEGER NOT NULL, "
+ COLUMN_SECOND + " INTEGER NOT NULL, " + COLUMN_SECOND + " INTEGER NOT NULL, "

View File

@ -0,0 +1,38 @@
package com.philliphsu.clock2.stopwatch;
import android.content.Context;
import com.philliphsu.clock2.AsyncDatabaseTableUpdateHandler;
import com.philliphsu.clock2.alarms.ScrollHandler;
/**
* Created by Phillip Hsu on 8/9/2016.
*/
public class AsyncLapsTableUpdateHandler extends AsyncDatabaseTableUpdateHandler<Lap, LapsTableManager> {
public AsyncLapsTableUpdateHandler(Context context, ScrollHandler scrollHandler) {
super(context, scrollHandler);
}
@Override
protected LapsTableManager getTableManager(Context context) {
return new LapsTableManager(context);
}
// ===================== DO NOT IMPLEMENT =========================
@Override
protected void onPostAsyncDelete(Integer result, Lap item) {
// Leave blank.
}
@Override
protected void onPostAsyncInsert(Long result, Lap item) {
// Leave blank.
}
@Override
protected void onPostAsyncUpdate(Long result, Lap item) {
// Leave blank.
}
}

View File

@ -0,0 +1,352 @@
package com.philliphsu.clock2.stopwatch;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Handler;
import android.os.Message;
import android.os.SystemClock;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.widget.TextView;
import java.util.Formatter;
import java.util.IllegalFormatException;
import java.util.Locale;
/**
* Created by Phillip Hsu on 8/9/2016.
*
* A modified version of the framework's Chronometer widget that shows
* up to hundredths of a second.
*/
public class ChronometerWithMillis extends TextView {
private static final String TAG = "ChronometerWithMillis";
/**
* A callback that notifies when the chronometer has incremented on its own.
*/
public interface OnChronometerTickListener {
/**
* Notification that the chronometer has changed.
*/
void onChronometerTick(ChronometerWithMillis chronometer);
}
private long mBase;
private long mNow; // the currently displayed time
private boolean mVisible;
private boolean mStarted;
private boolean mRunning;
private boolean mLogged;
private String mFormat;
private Formatter mFormatter;
private Locale mFormatterLocale;
private Object[] mFormatterArgs = new Object[1];
private StringBuilder mFormatBuilder;
private OnChronometerTickListener mOnChronometerTickListener;
private StringBuilder mRecycle = new StringBuilder(8);
private static final int TICK_WHAT = 2;
/**
* Initialize this Chronometer object.
* Sets the base to the current time.
*/
public ChronometerWithMillis(Context context) {
this(context, null, 0);
}
/**
* Initialize with standard view layout information.
* Sets the base to the current time.
*/
public ChronometerWithMillis(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
/**
* Initialize with standard view layout information and style.
* Sets the base to the current time.
*/
public ChronometerWithMillis(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// final TypedArray a = context.obtainStyledAttributes(
// attrs, com.android.internal.R.styleable.Chronometer, defStyleAttr, 0);
// setFormat(a.getString(com.android.internal.R.styleable.Chronometer_format));
// a.recycle();
init();
}
@TargetApi(21)
public ChronometerWithMillis(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
// final TypedArray a = context.obtainStyledAttributes(
// attrs, com.android.internal.R.styleable.Chronometer, defStyleAttr, defStyleRes);
// setFormat(a.getString(com.android.internal.R.styleable.Chronometer_format));
// a.recycle();
init();
}
private void init() {
mBase = SystemClock.elapsedRealtime();
updateText(mBase);
}
/**
* Set the time that the count-up timer is in reference to.
*
* @param base Use the {@link SystemClock#elapsedRealtime} time base.
*/
// @android.view.RemotableViewMethod
public void setBase(long base) {
mBase = base;
dispatchChronometerTick();
updateText(SystemClock.elapsedRealtime());
}
/**
* Return the base time as set through {@link #setBase}.
*/
public long getBase() {
return mBase;
}
/**
* Equivalent to {@link #setBase(long) setBase(SystemClock.elapsedRealtime() - elapsed)}.
*/
public void setElapsed(long elapsed) {
setBase(SystemClock.elapsedRealtime() - elapsed);
}
/**
* Sets the format string used for display. The Chronometer will display
* this string, with the first "%s" replaced by the current timer value in
* "MM:SS" or "H:MM:SS" form.
*
* If the format string is null, or if you never call setFormat(), the
* Chronometer will simply display the timer value in "MM:SS" or "H:MM:SS"
* form.
*
* @param format the format string.
*/
// @android.view.RemotableViewMethod
public void setFormat(String format) {
mFormat = format;
if (format != null && mFormatBuilder == null) {
mFormatBuilder = new StringBuilder(format.length() * 2);
}
}
/**
* Returns the current format string as set through {@link #setFormat}.
*/
public String getFormat() {
return mFormat;
}
/**
* Sets the listener to be called when the chronometer changes.
*
* @param listener The listener.
*/
public void setOnChronometerTickListener(OnChronometerTickListener listener) {
mOnChronometerTickListener = listener;
}
/**
* @return The listener (may be null) that is listening for chronometer change
* events.
*/
public OnChronometerTickListener getOnChronometerTickListener() {
return mOnChronometerTickListener;
}
/**
* Start counting up. This does not affect the base as set from {@link #setBase}, just
* the view display.
*
* Chronometer works by regularly scheduling messages to the handler, even when the
* Widget is not visible. To make sure resource leaks do not occur, the user should
* make sure that each start() call has a reciprocal call to {@link #stop}.
*/
public void start() {
mStarted = true;
updateRunning();
}
/**
* Stop counting up. This does not affect the base as set from {@link #setBase}, just
* the view display.
*
* This stops the messages to the handler, effectively releasing resources that would
* be held as the chronometer is running, via {@link #start}.
*/
public void stop() {
mStarted = false;
updateRunning();
}
/**
* The same as calling {@link #start} or {@link #stop}.
* @hide pending API council approval
*/
// @android.view.RemotableViewMethod
public void setStarted(boolean started) {
mStarted = started;
updateRunning();
}
public boolean isRunning() {
return mRunning;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mVisible = false;
updateRunning();
}
@Override
protected void onWindowVisibilityChanged(int visibility) {
Log.d(TAG, "onWindowVisibilityChanged()");
super.onWindowVisibilityChanged(visibility);
mVisible = visibility == VISIBLE;
updateRunning();
}
private synchronized void updateText(long now) {
mNow = now;
long millis = now - mBase;
String text = DateUtils.formatElapsedTime(mRecycle, millis / 1000/*needs to be in seconds*/);
Locale loc = Locale.getDefault();
if (mFormat != null) {
if (mFormatter == null || !loc.equals(mFormatterLocale)) {
mFormatterLocale = loc;
mFormatter = new Formatter(mFormatBuilder, loc);
}
mFormatBuilder.setLength(0);
mFormatterArgs[0] = text;
try {
mFormatter.format(mFormat, mFormatterArgs);
text = mFormatBuilder.toString();
} catch (IllegalFormatException ex) {
if (!mLogged) {
Log.w(TAG, "Illegal format string: " + mFormat);
mLogged = true;
}
}
}
long centiseconds = (millis % 1000) / 10;
String centisecondsText = String.format(loc,
// TODO: Different locales use different decimal marks.
// The two most common are . and ,
// Consider removing the . and just let the size span
// represent this as fractional seconds?
// ...or figure out how to get the correct mark for the
// current locale.
// It looks like Google's Clock app strictly uses .
".%02d", // The . before % is not a format specifier
centiseconds);
setText(text.concat(centisecondsText));
}
private void updateRunning() {
boolean running = mVisible && mStarted;
if (running != mRunning) {
if (running) {
Log.d(TAG, "Running");
updateText(SystemClock.elapsedRealtime());
dispatchChronometerTick();
mHandler.sendMessageDelayed(Message.obtain(mHandler, TICK_WHAT), 10);
} else {
Log.d(TAG, "Not running anymore");
mHandler.removeMessages(TICK_WHAT);
}
mRunning = running;
}
}
private Handler mHandler = new Handler() {
public void handleMessage(Message m) {
if (mRunning) {
updateText(SystemClock.elapsedRealtime());
dispatchChronometerTick();
sendMessageDelayed(Message.obtain(this, TICK_WHAT), 10);
}
}
};
void dispatchChronometerTick() {
if (mOnChronometerTickListener != null) {
mOnChronometerTickListener.onChronometerTick(this);
}
}
private static final int MIN_IN_SEC = 60;
private static final int HOUR_IN_SEC = MIN_IN_SEC*60;
// private static String formatDuration(long ms) {
// final Resources res = Resources.getSystem();
// final StringBuilder text = new StringBuilder();
//
// int duration = (int) (ms / DateUtils.SECOND_IN_MILLIS);
// if (duration < 0) {
// duration = -duration;
// }
//
// int h = 0;
// int m = 0;
//
// if (duration >= HOUR_IN_SEC) {
// h = duration / HOUR_IN_SEC;
// duration -= h * HOUR_IN_SEC;
// }
// if (duration >= MIN_IN_SEC) {
// m = duration / MIN_IN_SEC;
// duration -= m * MIN_IN_SEC;
// }
// int s = duration;
//
// try {
// if (h > 0) {
// text.append(res.getQuantityString(
// com.android.internal.R.plurals.duration_hours, h, h));
// }
// if (m > 0) {
// if (text.length() > 0) {
// text.append(' ');
// }
// text.append(res.getQuantityString(
// com.android.internal.R.plurals.duration_minutes, m, m));
// }
//
// if (text.length() > 0) {
// text.append(' ');
// }
// text.append(res.getQuantityString(
// com.android.internal.R.plurals.duration_seconds, s, s));
// } catch (Resources.NotFoundException e) {
// // Ignore; plurals throws an exception for an untranslated quantity for a given locale.
// return null;
// }
// return text.toString();
// }
//
// @Override
// public CharSequence getContentDescription() {
// return formatDuration(mNow - mBase);
// }
//
// @Override
// public CharSequence getAccessibilityClassName() {
// return ChronometerWithMillis.class.getName();
// }
}

View File

@ -0,0 +1,128 @@
package com.philliphsu.clock2.stopwatch;
import android.os.SystemClock;
import com.philliphsu.clock2.model.ObjectWithId;
/**
* Created by Phillip Hsu on 8/8/2016.
*/
public class Lap extends ObjectWithId {
private long t1;
private long t2;
private long pauseTime;
private String totalTimeText;
public Lap() {
// TOneverDO: nanos because chronometer expects times in the elapsedRealtime base
t1 = SystemClock.elapsedRealtime();
}
public long t1() {
return t1;
}
public long t2() {
return t2;
}
public long pauseTime() {
return pauseTime;
}
public String totalTimeText() {
return totalTimeText;
}
public void pause() {
if (isEnded())
throw new IllegalStateException("Cannot pause a Lap that has already ended");
if (!isRunning())
throw new IllegalStateException("Cannot pause a Lap that is already paused");
pauseTime = SystemClock.elapsedRealtime();
}
public void resume() {
if (isEnded())
throw new IllegalStateException("Cannot resume a Lap that has already ended");
if (isRunning())
throw new IllegalStateException("Cannot resume a Lap that is not paused");
t1 += SystemClock.elapsedRealtime() - pauseTime;
pauseTime = 0;
}
public void end(String totalTime) {
if (isEnded())
throw new IllegalStateException("Cannot end a Lap that has already ended");
t2 = SystemClock.elapsedRealtime();
totalTimeText = totalTime;
pauseTime = 0;
}
public long elapsed() {
if (isRunning())
return SystemClock.elapsedRealtime() - t1;
else if (isEnded())
return t2 - t1;
else return pauseTime - t1;
}
public boolean isRunning() {
return !isEnded() && pauseTime == 0;
}
public boolean isEnded() {
return t2 > 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Lap lap = (Lap) o;
if (t1 != lap.t1) return false;
if (t2 != lap.t2) return false;
if (pauseTime != lap.pauseTime) return false;
return totalTimeText != null ? totalTimeText.equals(lap.totalTimeText) : lap.totalTimeText == null;
}
@Override
public int hashCode() {
int result = (int) (t1 ^ (t1 >>> 32));
result = 31 * result + (int) (t2 ^ (t2 >>> 32));
result = 31 * result + (int) (pauseTime ^ (pauseTime >>> 32));
result = 31 * result + (totalTimeText != null ? totalTimeText.hashCode() : 0);
return result;
}
@Override
public String toString() {
return "Lap{" +
"t1=" + t1 +
", t2=" + t2 +
", pauseTime=" + pauseTime +
", totalTimeText='" + totalTimeText + '\'' +
'}';
}
// ================ TO ONLY BE CALLED BY LapsTableManager ================
public void setT1(long t1) {
this.t1 = t1;
}
public void setT2(long t2) {
this.t2 = t2;
}
public void setPauseTime(long pauseTime) {
this.pauseTime = pauseTime;
}
public void setTotalTimeText(String totalTimeText) {
this.totalTimeText = totalTimeText;
}
}

View File

@ -0,0 +1,26 @@
package com.philliphsu.clock2.stopwatch;
import android.database.Cursor;
import com.philliphsu.clock2.model.BaseItemCursor;
/**
* Created by Phillip Hsu on 8/8/2016.
*/
public class LapCursor extends BaseItemCursor<Lap> {
public LapCursor(Cursor cursor) {
super(cursor);
}
@Override
public Lap getItem() {
Lap lap = new Lap();
lap.setId(getLong(getColumnIndexOrThrow(LapsTable.COLUMN_ID)));
lap.setT1(getLong(getColumnIndexOrThrow(LapsTable.COLUMN_T1)));
lap.setT2(getLong(getColumnIndexOrThrow(LapsTable.COLUMN_T2)));
lap.setPauseTime(getLong(getColumnIndexOrThrow(LapsTable.COLUMN_PAUSE_TIME)));
lap.setTotalTimeText(getString(getColumnIndexOrThrow(LapsTable.COLUMN_TOTAL_TIME_TEXT)));
return lap;
}
}

View File

@ -0,0 +1,63 @@
package com.philliphsu.clock2.stopwatch;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import com.philliphsu.clock2.BaseViewHolder;
import com.philliphsu.clock2.R;
import butterknife.Bind;
/**
* Created by Phillip Hsu on 8/8/2016.
*/
public class LapViewHolder extends BaseViewHolder<Lap> {
@Bind(R.id.lap_number) TextView mLapNumber;
@Bind(R.id.elapsed_time) ChronometerWithMillis mElapsedTime;
@Bind(R.id.total_time) TextView mTotalTime;
public LapViewHolder(ViewGroup parent) {
super(parent, R.layout.item_lap, null);
}
@Override
public void onBind(Lap lap) {
super.onBind(lap);
if (getItemViewType() == LapsAdapter.VIEW_TYPE_FIRST_LAP) {
itemView.setVisibility(View.GONE);
} else {
mLapNumber.setText(String.valueOf(lap.getId()));
bindElapsedTime(lap);
bindTotalTime(lap);
}
}
private void bindElapsedTime(Lap lap) {
// In case we're reusing a chronometer instance that could be running:
// If the lap is not running, this just guarantees the chronometer
// won't tick, regardless of whether it was running.
// If the lap is running, we don't care whether the chronometer is
// also running, because we call start() right after. Stopping it just
// guarantees that, if it was running, we don't deliver another set of
// concurrent messages to its handler.
mElapsedTime.stop();
// We're going to forget about the + sign in front of the text. I think
// the 'Elapsed' header column is sufficient to convey what this timer means.
// (Don't want to figure out a solution)
mElapsedTime.setElapsed(lap.elapsed());
if (lap.isRunning()) {
mElapsedTime.start();
}
}
private void bindTotalTime(Lap lap) {
if (lap.isEnded()) {
mTotalTime.setVisibility(View.VISIBLE);
mTotalTime.setText(lap.totalTimeText());
} else {
mTotalTime.setVisibility(View.INVISIBLE);
}
}
}

View File

@ -0,0 +1,34 @@
package com.philliphsu.clock2.stopwatch;
import android.view.ViewGroup;
import com.philliphsu.clock2.BaseCursorAdapter;
import com.philliphsu.clock2.OnListItemInteractionListener;
/**
* Created by Phillip Hsu on 8/9/2016.
*/
public class LapsAdapter extends BaseCursorAdapter<Lap, LapViewHolder, LapCursor> {
public static final int VIEW_TYPE_FIRST_LAP = 1; // TOneverDO: 0, that's the default view type
public LapsAdapter() {
super(null/*OnListItemInteractionListener*/);
}
@Override
protected LapViewHolder onCreateViewHolder(ViewGroup parent, OnListItemInteractionListener<Lap> listener, int viewType) {
// TODO: Consider defining a view type for the lone first lap. We should persist this first lap
// when is is created, but this view type will tell us it should not be visible until there
// are at least two laps.
// Or could we return null? Probably not because that will get passed into onBindVH, unless
// you check for null before accessing it.
// RecyclerView.ViewHolder has an internal field that holds viewType for us,
// and can be retrieved by the instance via getItemViewType().
return new LapViewHolder(parent);
}
@Override
public int getItemViewType(int position) {
return getItemCount() == 1 ? VIEW_TYPE_FIRST_LAP : super.getItemViewType(position);
}
}

View File

@ -0,0 +1,28 @@
package com.philliphsu.clock2.stopwatch;
import android.content.Context;
import com.philliphsu.clock2.model.SQLiteCursorLoader;
/**
* Created by Phillip Hsu on 8/9/2016.
*/
public class LapsCursorLoader extends SQLiteCursorLoader<Lap, LapCursor> {
public static final String ACTION_CHANGE_CONTENT
// TODO: Correct package prefix
= "com.philliphsu.clock2.model.LapsCursorLoader.action.CHANGE_CONTENT";
public LapsCursorLoader(Context context) {
super(context);
}
@Override
protected LapCursor loadCursor() {
return new LapsTableManager(getContext()).queryItems();
}
@Override
protected String getOnContentChangeAction() {
return ACTION_CHANGE_CONTENT;
}
}

View File

@ -0,0 +1,42 @@
package com.philliphsu.clock2.stopwatch;
import android.database.sqlite.SQLiteDatabase;
/**
* Created by Phillip Hsu on 8/8/2016.
*/
public final class LapsTable {
private LapsTable() {}
public static final String TABLE_LAPS = "laps";
public static final String COLUMN_ID = "_id";
public static final String COLUMN_T1 = "elapsed";
public static final String COLUMN_T2 = "total";
public static final String COLUMN_PAUSE_TIME = "pause_time";
public static final String COLUMN_TOTAL_TIME_TEXT = "total_time_text";
public static final String SORT_ORDER = COLUMN_ID + " DESC";
public static void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE " + TABLE_LAPS + " ("
// https://sqlite.org/autoinc.html
// If the AUTOINCREMENT keyword appears after INTEGER PRIMARY KEY, that changes the
// automatic ROWID assignment algorithm to prevent the reuse of ROWIDs over the
// lifetime of the database. In other words, the purpose of AUTOINCREMENT is to
// prevent the reuse of ROWIDs from previously deleted rows.
// If we ever clear the laps of a stopwatch timing, we want the next timing's laps
// to begin again from id = 1. Using AUTOINCREMENT prevents that from happening;
// the laps will begin from the last ID used plus one.
+ COLUMN_ID + " INTEGER PRIMARY KEY, "
+ COLUMN_T1 + " INTEGER NOT NULL, "
+ COLUMN_T2 + " INTEGER NOT NULL, "
+ COLUMN_PAUSE_TIME + " INTEGER NOT NULL, "
+ COLUMN_TOTAL_TIME_TEXT + " TEXT);");
}
public static void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS " + TABLE_LAPS);
onCreate(db);
}
}

View File

@ -0,0 +1,70 @@
package com.philliphsu.clock2.stopwatch;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import com.philliphsu.clock2.model.DatabaseTableManager;
/**
* Created by Phillip Hsu on 8/8/2016.
*/
public class LapsTableManager extends DatabaseTableManager<Lap> {
public LapsTableManager(Context context) {
super(context);
}
@Override
protected String getQuerySortOrder() {
return LapsTable.SORT_ORDER;
}
@Override
public LapCursor queryItem(long id) {
return wrapInLapCursor(super.queryItem(id));
}
@Override
public LapCursor queryItems() {
return wrapInLapCursor(super.queryItems());
}
@Override
protected LapCursor queryItems(String where, String limit) {
return wrapInLapCursor(super.queryItems(where, limit));
}
// public LapCursor queryTwoMostRecentLaps(long currentLapId, long previousLapId) {
// String where = LapsTable.COLUMN_ID + " = " + currentLapId
// + " OR " + LapsTable.COLUMN_ID + " = " + previousLapId;
// LapCursor cursor = queryItems(where, "2");
// cursor.moveToFirst();
// return cursor;
// }
@Override
protected String getTableName() {
return LapsTable.TABLE_LAPS;
}
@Override
protected ContentValues toContentValues(Lap lap) {
ContentValues cv = new ContentValues();
// cv.put(LapsTable.COLUMN_ID, lap.getId()); // NEVER SET THE ID, BECAUSE THE DATABASE SETS AN AUTOINCREMENTING PRIMARY KEY FOR YOU!
cv.put(LapsTable.COLUMN_T1, lap.t1());
cv.put(LapsTable.COLUMN_T2, lap.t2());
cv.put(LapsTable.COLUMN_PAUSE_TIME, lap.pauseTime());
cv.put(LapsTable.COLUMN_TOTAL_TIME_TEXT, lap.totalTimeText());
return cv;
}
@Override
protected String getOnContentChangeAction() {
return LapsCursorLoader.ACTION_CHANGE_CONTENT;
}
private LapCursor wrapInLapCursor(Cursor c) {
return new LapCursor(c);
}
}

View File

@ -0,0 +1,300 @@
package com.philliphsu.clock2.stopwatch;
import android.animation.ObjectAnimator;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.SystemClock;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.design.widget.FloatingActionButton;
import android.support.v4.content.Loader;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import com.philliphsu.clock2.R;
import com.philliphsu.clock2.RecyclerViewFragment;
import com.philliphsu.clock2.util.ProgressBarUtils;
import butterknife.Bind;
import butterknife.OnClick;
/**
* Created by Phillip Hsu on 8/8/2016.
*/
public class StopwatchFragment extends RecyclerViewFragment<
Lap,
LapViewHolder,
LapCursor,
LapsAdapter> {
private static final String TAG = "StopwatchFragment";
private static final String KEY_START_TIME = "start_time";
private static final String KEY_PAUSE_TIME = "pause_time";
private static final String KEY_CHRONOMETER_RUNNING = "chronometer_running";
private long mStartTime;
private long mPauseTime;
private Lap mCurrentLap;
private Lap mPreviousLap;
private AsyncLapsTableUpdateHandler mUpdateHandler;
private ObjectAnimator mProgressAnimator;
private SharedPreferences mPrefs;
@Bind(R.id.chronometer) ChronometerWithMillis mChronometer;
@Bind(R.id.new_lap) ImageButton mNewLapButton;
@Bind(R.id.fab) FloatingActionButton mFab;
@Bind(R.id.stop) ImageButton mStopButton;
@Bind(R.id.progress_bar) ProgressBar mProgressBar;
/**
* This is called only when a new instance of this Fragment is being created,
* especially if the user is navigating to this tab for the first time in
* this app session.
*/
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mUpdateHandler = new AsyncLapsTableUpdateHandler(getActivity(), null/*we shouldn't need a scroll handler*/);
mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity());
mStartTime = mPrefs.getLong(KEY_START_TIME, 0);
mPauseTime = mPrefs.getLong(KEY_PAUSE_TIME, 0);
Log.d(TAG, "mStartTime = " + mStartTime
+ ", mPauseTime = " + mPauseTime);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
// TODO: Apply size span on chronom
View view = super.onCreateView(inflater, container, savedInstanceState);
if (mStartTime > 0) {
long base = mStartTime;
if (mPauseTime > 0) {
base += SystemClock.elapsedRealtime() - mPauseTime;
// We're not done pausing yet, so don't reset mPauseTime.
}
mChronometer.setBase(base);
}
if (mPrefs.getBoolean(KEY_CHRONOMETER_RUNNING, false)) {
mChronometer.start();
}
updateButtonControls();
return view;
}
/**
* If the user navigates away, this is the furthest point in the lifecycle
* this Fragment gets to. Here, the view hierarchy returned from onCreateView()
* is destroyed--the Fragment itself is NOT destroyed. If the user navigates back
* to this tab, this Fragment goes through its lifecycle beginning from onCreateView().
*
* TODO: Verify that members are not reset.
*/
@Override
public void onDestroyView() {
super.onDestroyView();
Log.d(TAG, "onDestroyView()");
Log.d(TAG, "mStartTime = " + mStartTime
+ ", mPauseTime = " + mPauseTime);
}
@Override
public Loader<LapCursor> onCreateLoader(int id, Bundle args) {
return new LapsCursorLoader(getActivity());
}
@Override
public void onLoadFinished(Loader<LapCursor> loader, LapCursor data) {
super.onLoadFinished(loader, data);
// TODO: Will manipulating the cursor's position here affect the current
// position in the adapter? Should we make a defensive copy and manipulate
// that copy instead?
if (data.moveToFirst()) {
mCurrentLap = data.getItem();
Log.d(TAG, "Current lap ID = " + mCurrentLap.getId());
}
if (data.moveToNext()) {
mPreviousLap = data.getItem();
Log.d(TAG, "Previous lap ID = " + mPreviousLap.getId());
}
if (mChronometer.isRunning() && mCurrentLap != null && mPreviousLap != null) {
// We really only want to start a new animator when the NEWLY RETRIEVED current
// and previous laps are different (i.e. different laps, NOT merely different instances)
// from the CURRENT current and previous laps, as referenced by mCurrentLap and mPreviousLap.
// However, both equals() and == are insufficient. Our cursor's getItem() will always
// create new instances of Lap representing the underlying data, so an '== test' will
// always fail to convey our intention. Also, equals() would fail especially when the
// physical lap is paused/resumed, because the two instances in comparison
// (the retrieved and current) would obviously
// have different values for, e.g., t1 and pauseTime.
//
// Therefore, we'll just always end the previous animator and start a new one.
// TODO: We may as well move the contents of this method here, since we're the only caller.
startNewProgressBarAnimator();
}
}
@Nullable
@Override
protected LapsAdapter getAdapter() {
if (super.getAdapter() != null)
return super.getAdapter();
return new LapsAdapter();
}
@Override
protected int contentLayout() {
return R.layout.fragment_stopwatch;
}
@OnClick(R.id.new_lap)
void addNewLap() {
if (!mChronometer.isRunning()) {
Log.d(TAG, "Cannot add new lap");
return;
}
if (mCurrentLap != null) {
mCurrentLap.end(mChronometer.getText().toString());
}
mPreviousLap = mCurrentLap;
mCurrentLap = new Lap();
if (mPreviousLap != null) {
// if (getAdapter().getItemCount() == 0) {
// mUpdateHandler.asyncInsert(mPreviousLap);
// } else {
mUpdateHandler.asyncUpdate(mPreviousLap.getId(), mPreviousLap);
// }
}
mUpdateHandler.asyncInsert(mCurrentLap);
// This would end up being called twice: here, and in onLoadFinished(), because the
// table updates will prompt us to requery.
// startNewProgressBarAnimator();
}
@OnClick(R.id.fab)
void startPause() {
if (mChronometer.isRunning()) {
mPauseTime = SystemClock.elapsedRealtime();
mChronometer.stop();
mCurrentLap.pause();
mUpdateHandler.asyncUpdate(mCurrentLap.getId(), mCurrentLap);
// No issues controlling the animator here, because onLoadFinished() can't
// call through to startNewProgressBarAnimator(), because by that point
// the chronometer won't be running.
if (mProgressAnimator != null) {
// We may as well call cancel(), since our resume() call would be
// rendered meaningless.
// mProgressAnimator.pause();
mProgressAnimator.cancel();
}
} else {
if (mStartTime == 0) {
// TODO: I'm strongly considering inserting the very first lap alone.
// We'll need to tell the adapter to just hide the corresponding VH
// until a second lap is added.
// addNewLap() won't call through unless chronometer is running, which
// we can't start until we compute mStartTime
mCurrentLap = new Lap();
mUpdateHandler.asyncInsert(mCurrentLap);
}
mStartTime += SystemClock.elapsedRealtime() - mPauseTime;
mPauseTime = 0;
mChronometer.setBase(mStartTime);
mChronometer.start();
if (!mCurrentLap.isRunning()) {
mCurrentLap.resume();
mUpdateHandler.asyncUpdate(mCurrentLap.getId(), mCurrentLap);
}
// This animator instance will end up having end() called on it. When
// the table update prompts us to requery, onLoadFinished will be called as a result.
// There, it calls startNewProgressAnimator() to end this animation and starts an
// entirely new animator instance.
// if (mProgressAnimator != null) {
// mProgressAnimator.resume();
// }
}
updateButtonControls();
savePrefs();
}
@OnClick(R.id.stop)
void stop() {
mChronometer.stop();
mChronometer.setBase(SystemClock.elapsedRealtime());
mStartTime = 0;
mPauseTime = 0;
mCurrentLap = null;
mPreviousLap = null;
mUpdateHandler.asyncClear(); // Clear laps
// No issues controlling the animator here, because onLoadFinished() can't
// call through to startNewProgressBarAnimator(), because by that point
// the chronometer won't be running.
if (mProgressAnimator != null) {
mProgressAnimator.end();
}
mProgressAnimator = null;
updateButtonControls();
savePrefs();
}
private void updateButtonControls() {
boolean started = mStartTime > 0;
int vis = started ? View.VISIBLE : View.INVISIBLE;
mNewLapButton.setVisibility(vis);
mStopButton.setVisibility(vis);
// TODO: pause and start icon, resp.
mFab.setImageResource(mChronometer.isRunning() ? 0 : 0);
}
private void startNewProgressBarAnimator() {
if (mProgressAnimator != null) {
mProgressAnimator.end();
}
long timeRemaining = mPreviousLap.elapsed() - mCurrentLap.elapsed();
if (timeRemaining <= 0)
return;
// The cast is necessary, or else we'd have integer division between two longs and we'd
// always get zero since the numerator will always be less than the denominator.
double ratioTimeRemaining = timeRemaining / (double) mPreviousLap.elapsed();
mProgressAnimator = ProgressBarUtils.startNewAnimator(
mProgressBar, ratioTimeRemaining, timeRemaining);
}
private void savePrefs() {
mPrefs.edit().putLong(KEY_START_TIME, mStartTime)
.putLong(KEY_PAUSE_TIME, mPauseTime)
.putBoolean(KEY_CHRONOMETER_RUNNING, mChronometer.isRunning())
.apply();
}
// ======================= DO NOT IMPLEMENT ============================
@Override
public void onFabClick() {
// DO NOT THROW AN UNSUPPORTED OPERATION EXCEPTION.
}
@Override
protected void onScrolledToStableId(long id, int position) {
throw new UnsupportedOperationException();
}
@Override
public void onListItemClick(Lap item, int position) {
throw new UnsupportedOperationException();
}
@Override
public void onListItemDeleted(Lap item) {
throw new UnsupportedOperationException();
}
@Override
public void onListItemUpdate(Lap item, int position) {
throw new UnsupportedOperationException();
}
}

View File

@ -13,6 +13,7 @@ import com.philliphsu.clock2.BaseViewHolder;
import com.philliphsu.clock2.OnListItemInteractionListener; import com.philliphsu.clock2.OnListItemInteractionListener;
import com.philliphsu.clock2.R; import com.philliphsu.clock2.R;
import com.philliphsu.clock2.Timer; import com.philliphsu.clock2.Timer;
import com.philliphsu.clock2.util.ProgressBarUtils;
import butterknife.Bind; import butterknife.Bind;
import butterknife.OnClick; import butterknife.OnClick;
@ -85,6 +86,10 @@ public class TimerViewHolder extends BaseViewHolder<Timer> {
// concurrent messages to its handler. // concurrent messages to its handler.
mChronometer.stop(); mChronometer.stop();
// TODO: I think we can simplify all this to just:
// mChronometer.setDuration(timer.timeRemaining())
// if we make the modification to the method as
// described in the Timer class.
if (!timer.hasStarted()) { if (!timer.hasStarted()) {
// Set the initial text // Set the initial text
mChronometer.setDuration(timer.duration()); mChronometer.setDuration(timer.duration());
@ -115,9 +120,8 @@ public class TimerViewHolder extends BaseViewHolder<Timer> {
} }
private void bindProgressBar(Timer timer) { private void bindProgressBar(Timer timer) {
mProgressBar.setMax(MAX_PROGRESS);
final long timeRemaining = timer.timeRemaining(); final long timeRemaining = timer.timeRemaining();
final int progress = (int) (MAX_PROGRESS * (double) timeRemaining / timer.duration()); double ratio = (double) timeRemaining / timer.duration();
// In case we're reusing an animator instance that could be running // In case we're reusing an animator instance that could be running
if (mProgressAnimator != null && mProgressAnimator.isRunning()) { if (mProgressAnimator != null && mProgressAnimator.isRunning()) {
@ -125,30 +129,13 @@ public class TimerViewHolder extends BaseViewHolder<Timer> {
} }
if (!timer.isRunning()) { if (!timer.isRunning()) {
mProgressBar.setProgress(progress); // If our scale were 1, then casting ratio to an int will ALWAYS
// truncate down to zero.
mProgressBar.setMax(100);
mProgressBar.setProgress((int) (100 * ratio));
} else { } else {
mProgressAnimator = ObjectAnimator.ofInt( mProgressAnimator = ProgressBarUtils.startNewAnimator(
// The object that has the property we wish to animate mProgressBar, ratio, timeRemaining);
mProgressBar,
// The name of the property of the object that identifies which setter method
// the animation will call to update its values. Here, a property name of
// "progress" will result in a call to the function setProgress() in ProgressBar.
// The docs for ObjectAnimator#setPropertyName() says that for best performance,
// the setter method should take a float or int parameter, and its return type
// should be void (both of which setProgress() satisfies).
"progress",
// The set of values to animate between. A single value implies that that value
// is the one being animated to. Two values imply starting and ending values.
// More than two values imply a starting value, values to animate through along
// the way, and an ending value (these values will be distributed evenly across
// the duration of the animation).
progress, 0);
mProgressAnimator.setDuration(timeRemaining);
// The algorithm that calculates intermediate values between keyframes. We use linear
// interpolation so that the animation runs at constant speed.
mProgressAnimator.setInterpolator(null/*results in linear interpolation*/);
// This MUST be run on the UI thread.
mProgressAnimator.start();
} }
} }
} }

View File

@ -0,0 +1,48 @@
package com.philliphsu.clock2.util;
import android.animation.ObjectAnimator;
import android.widget.ProgressBar;
/**
* Created by Phillip Hsu on 8/10/2016.
*/
public class ProgressBarUtils {
private static final int MAX_PROGRESS = 100000;
/**
* Constructs and starts a new countdown ObjectAnimator with the given properties.
* @param ratio The initial ratio to show on the progress bar. This will be scaled
* by {@link #MAX_PROGRESS}. A ratio of 1 means the bar is full.
* @return the created ObjectAnimator for holding a reference to
*/
public static ObjectAnimator startNewAnimator(final ProgressBar bar, final double ratio, final long duration) {
bar.setMax(MAX_PROGRESS);
final int progress = (int) (MAX_PROGRESS * ratio);
ObjectAnimator animator = ObjectAnimator.ofInt(
// The object that has the property we wish to animate
bar,
// The name of the property of the object that identifies which setter method
// the animation will call to update its values. Here, a property name of
// "progress" will result in a call to the function setProgress() in ProgressBar.
// The docs for ObjectAnimator#setPropertyName() says that for best performance,
// the setter method should take a float or int parameter, and its return type
// should be void (both of which setProgress() satisfies).
"progress",
// The set of values to animate between. A single value implies that that value
// is the one being animated to. Two values imply starting and ending values.
// More than two values imply a starting value, values to animate through along
// the way, and an ending value (these values will be distributed evenly across
// the duration of the animation).
// TODO: Consider leaving the set of values up to the client. Currently, we
// have hardcoded this animator to be a "countdown" progress bar. This is
// sufficient for our current needs.
progress, 0);
animator.setDuration(duration);
// The algorithm that calculates intermediate values between keyframes. We use linear
// interpolation so that the animation runs at constant speed.
animator.setInterpolator(null/*results in linear interpolation*/);
// This MUST be run on the UI thread.
animator.start();
return animator;
}
}

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/top_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorPrimary"
android:padding="16dp">
<com.philliphsu.clock2.stopwatch.ChronometerWithMillis
android:id="@+id/chronometer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:textSize="45sp"
style="@style/TextAppearance.AppCompat.Inverse"
android:layout_marginBottom="16dp"/>
<ImageButton
android:id="@+id/new_lap"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="?selectableItemBackground"
android:layout_below="@id/chronometer"
android:layout_alignParentStart="true"
android:src="@drawable/ic_half_day_1_black_24dp"/>
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/chronometer"
android:layout_centerInParent="true"/>
<ImageButton
android:id="@+id/stop"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="?selectableItemBackground"
android:layout_below="@id/chronometer"
android:layout_alignParentEnd="true"
android:src="@drawable/ic_half_day_1_black_24dp"/>
</RelativeLayout>
<!-- RecyclerView -->
<include layout="@layout/fragment_recycler_view"/>
</LinearLayout>
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_anchor="@id/top_panel"
app:layout_anchorGravity="bottom"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -42,7 +42,7 @@
android:id="@+id/fab" android:id="@+id/fab"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="end|bottom" android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin" android:layout_margin="@dimen/fab_margin"
android:src="@android:drawable/ic_dialog_email"/> android:src="@android:drawable/ic_dialog_email"/>

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/top_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/colorPrimary"
android:padding="16dp">
<com.philliphsu.clock2.stopwatch.ChronometerWithMillis
android:id="@+id/chronometer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textSize="45sp"
style="@style/TextAppearance.AppCompat.Inverse"/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/new_lap"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="?selectableItemBackground"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_half_day_1_black_24dp"/>
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"/>
<ImageButton
android:id="@+id/stop"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="?selectableItemBackground"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_half_day_1_black_24dp"/>
</RelativeLayout>
</LinearLayout>
<!-- RecyclerView -->
<include layout="@layout/fragment_recycler_view"/>
</LinearLayout>
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_anchor="@id/top_panel"
app:layout_anchorGravity="bottom"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/lap_number"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
style="@style/TextAppearance.AppCompat"/>
<com.philliphsu.clock2.stopwatch.ChronometerWithMillis
android:id="@+id/elapsed_time"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"/>
<TextView
android:id="@+id/total_time"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
style="@style/TextAppearance.AppCompat"/>
</LinearLayout>