diff --git a/app/src/main/java/com/philliphsu/clock2/AsyncDatabaseTableUpdateHandler.java b/app/src/main/java/com/philliphsu/clock2/AsyncDatabaseTableUpdateHandler.java index 7878b5d..b24f924 100644 --- a/app/src/main/java/com/philliphsu/clock2/AsyncDatabaseTableUpdateHandler.java +++ b/app/src/main/java/com/philliphsu/clock2/AsyncDatabaseTableUpdateHandler.java @@ -53,10 +53,23 @@ public abstract class AsyncDatabaseTableUpdateHandler< }.execute(); } + public final void asyncClear() { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + mTableManager.clear(); + return null; + } + }.execute(); + } + protected final Context getContext() { 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 void onPostAsyncDelete(Integer result, T item); diff --git a/app/src/main/java/com/philliphsu/clock2/MainActivity.java b/app/src/main/java/com/philliphsu/clock2/MainActivity.java index ca8cece..da1ef05 100644 --- a/app/src/main/java/com/philliphsu/clock2/MainActivity.java +++ b/app/src/main/java/com/philliphsu/clock2/MainActivity.java @@ -2,6 +2,7 @@ package com.philliphsu.clock2; import android.content.Intent; import android.os.Bundle; +import android.os.Handler; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.TabLayout; import android.support.v4.app.Fragment; @@ -16,6 +17,7 @@ import android.widget.TextView; import com.philliphsu.clock2.alarms.AlarmsFragment; import com.philliphsu.clock2.settings.SettingsActivity; +import com.philliphsu.clock2.stopwatch.StopwatchFragment; import com.philliphsu.clock2.timers.TimersFragment; import butterknife.Bind; @@ -33,6 +35,13 @@ public class MainActivity extends BaseActivity { */ 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) ViewPager mViewPager; @@ -47,6 +56,76 @@ public class MainActivity extends BaseActivity { // primary sections of the activity. mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); 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.setupWithViewPager(mViewPager); @@ -62,6 +141,12 @@ public class MainActivity extends BaseActivity { // // Fragment's onActivityResult() will NOT be called. // mSectionsPagerAdapter.getCurrentFragment() // .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; if ((f = mSectionsPagerAdapter.getCurrentFragment()) instanceof RecyclerViewFragment) { ((RecyclerViewFragment) f).onFabClick(); @@ -156,6 +241,8 @@ public class MainActivity extends BaseActivity { return AlarmsFragment.newInstance(1); case 1: return new TimersFragment(); + case 2: + return new StopwatchFragment(); default: return PlaceholderFragment.newInstance(position + 1); } diff --git a/app/src/main/java/com/philliphsu/clock2/RecyclerViewFragment.java b/app/src/main/java/com/philliphsu/clock2/RecyclerViewFragment.java index 41ed964..6c74156 100644 --- a/app/src/main/java/com/philliphsu/clock2/RecyclerViewFragment.java +++ b/app/src/main/java/com/philliphsu/clock2/RecyclerViewFragment.java @@ -64,12 +64,6 @@ public abstract class RecyclerViewFragment< return new LinearLayoutManager(getActivity()); } - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - getLoaderManager().initLoader(0, null, this); - } - @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -79,6 +73,14 @@ public abstract class RecyclerViewFragment< 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 public void onLoadFinished(Loader loader, C data) { mAdapter.swapCursor(data); diff --git a/app/src/main/java/com/philliphsu/clock2/Timer.java b/app/src/main/java/com/philliphsu/clock2/Timer.java index 7bd55da..45a3bc6 100644 --- a/app/src/main/java/com/philliphsu/clock2/Timer.java +++ b/app/src/main/java/com/philliphsu/clock2/Timer.java @@ -64,6 +64,17 @@ public abstract class Timer extends ObjectWithId implements Parcelable { public long timeRemaining() { 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 isRunning() ? endTime - SystemClock.elapsedRealtime() diff --git a/app/src/main/java/com/philliphsu/clock2/model/AlarmsTable.java b/app/src/main/java/com/philliphsu/clock2/model/AlarmsTable.java index 5c203b8..6a10c92 100644 --- a/app/src/main/java/com/philliphsu/clock2/model/AlarmsTable.java +++ b/app/src/main/java/com/philliphsu/clock2/model/AlarmsTable.java @@ -68,7 +68,12 @@ public final class AlarmsTable { public static void onCreate(SQLiteDatabase db) { 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_MINUTES + " INTEGER NOT NULL, " + COLUMN_LABEL + " TEXT, " diff --git a/app/src/main/java/com/philliphsu/clock2/model/ClockAppDatabaseHelper.java b/app/src/main/java/com/philliphsu/clock2/model/ClockAppDatabaseHelper.java index 74d409a..b6f2ed8 100644 --- a/app/src/main/java/com/philliphsu/clock2/model/ClockAppDatabaseHelper.java +++ b/app/src/main/java/com/philliphsu/clock2/model/ClockAppDatabaseHelper.java @@ -4,6 +4,8 @@ import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import com.philliphsu.clock2.stopwatch.LapsTable; + /** * Created by Phillip Hsu on 7/30/2016. */ @@ -31,11 +33,13 @@ public class ClockAppDatabaseHelper extends SQLiteOpenHelper { public void onCreate(SQLiteDatabase db) { AlarmsTable.onCreate(db); TimersTable.onCreate(db); + LapsTable.onCreate(db); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { AlarmsTable.onUpgrade(db, oldVersion, newVersion); TimersTable.onUpgrade(db, oldVersion, newVersion); + LapsTable.onUpgrade(db, oldVersion, newVersion); } } diff --git a/app/src/main/java/com/philliphsu/clock2/model/DatabaseTableManager.java b/app/src/main/java/com/philliphsu/clock2/model/DatabaseTableManager.java index 6e574ea..1c26b29 100644 --- a/app/src/main/java/com/philliphsu/clock2/model/DatabaseTableManager.java +++ b/app/src/main/java/com/philliphsu/clock2/model/DatabaseTableManager.java @@ -117,6 +117,14 @@ public abstract class DatabaseTableManager { limit); // limit } + /** + * Deletes all rows in this table. + */ + public final void clear() { + mDbHelper.getWritableDatabase().delete(getTableName(), null/*all rows*/, null); + notifyContentChanged(); + } + private void notifyContentChanged() { LocalBroadcastHelper.sendBroadcast(mAppContext, getOnContentChangeAction()); } diff --git a/app/src/main/java/com/philliphsu/clock2/model/TimersTable.java b/app/src/main/java/com/philliphsu/clock2/model/TimersTable.java index 9fe79d8..6c692bb 100644 --- a/app/src/main/java/com/philliphsu/clock2/model/TimersTable.java +++ b/app/src/main/java/com/philliphsu/clock2/model/TimersTable.java @@ -37,7 +37,12 @@ public final class TimersTable { public static void onCreate(SQLiteDatabase db) { 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_MINUTE + " INTEGER NOT NULL, " + COLUMN_SECOND + " INTEGER NOT NULL, " diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/AsyncLapsTableUpdateHandler.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/AsyncLapsTableUpdateHandler.java new file mode 100644 index 0000000..4ce8092 --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/AsyncLapsTableUpdateHandler.java @@ -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 { + + 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. + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/ChronometerWithMillis.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/ChronometerWithMillis.java new file mode 100644 index 0000000..89524af --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/ChronometerWithMillis.java @@ -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(); +// } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/Lap.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/Lap.java new file mode 100644 index 0000000..4ec6f8a --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/Lap.java @@ -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; + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/LapCursor.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapCursor.java new file mode 100644 index 0000000..93a83c5 --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapCursor.java @@ -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 { + + 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; + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/LapViewHolder.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapViewHolder.java new file mode 100644 index 0000000..9605f6a --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapViewHolder.java @@ -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 { + + @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); + } + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsAdapter.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsAdapter.java new file mode 100644 index 0000000..fbab668 --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsAdapter.java @@ -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 { + 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 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); + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsCursorLoader.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsCursorLoader.java new file mode 100644 index 0000000..13f79a7 --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsCursorLoader.java @@ -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 { + 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; + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsTable.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsTable.java new file mode 100644 index 0000000..ad3425c --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsTable.java @@ -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); + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsTableManager.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsTableManager.java new file mode 100644 index 0000000..dd988be --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/LapsTableManager.java @@ -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 { + + 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); + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/stopwatch/StopwatchFragment.java b/app/src/main/java/com/philliphsu/clock2/stopwatch/StopwatchFragment.java new file mode 100644 index 0000000..f198399 --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/stopwatch/StopwatchFragment.java @@ -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 onCreateLoader(int id, Bundle args) { + return new LapsCursorLoader(getActivity()); + } + + @Override + public void onLoadFinished(Loader 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(); + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/timers/TimerViewHolder.java b/app/src/main/java/com/philliphsu/clock2/timers/TimerViewHolder.java index 19f135a..f4502c4 100644 --- a/app/src/main/java/com/philliphsu/clock2/timers/TimerViewHolder.java +++ b/app/src/main/java/com/philliphsu/clock2/timers/TimerViewHolder.java @@ -13,6 +13,7 @@ import com.philliphsu.clock2.BaseViewHolder; import com.philliphsu.clock2.OnListItemInteractionListener; import com.philliphsu.clock2.R; import com.philliphsu.clock2.Timer; +import com.philliphsu.clock2.util.ProgressBarUtils; import butterknife.Bind; import butterknife.OnClick; @@ -85,6 +86,10 @@ public class TimerViewHolder extends BaseViewHolder { // concurrent messages to its handler. 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()) { // Set the initial text mChronometer.setDuration(timer.duration()); @@ -115,9 +120,8 @@ public class TimerViewHolder extends BaseViewHolder { } private void bindProgressBar(Timer timer) { - mProgressBar.setMax(MAX_PROGRESS); 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 if (mProgressAnimator != null && mProgressAnimator.isRunning()) { @@ -125,30 +129,13 @@ public class TimerViewHolder extends BaseViewHolder { } 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 { - mProgressAnimator = ObjectAnimator.ofInt( - // The object that has the property we wish to animate - 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(); + mProgressAnimator = ProgressBarUtils.startNewAnimator( + mProgressBar, ratio, timeRemaining); } } } diff --git a/app/src/main/java/com/philliphsu/clock2/util/ProgressBarUtils.java b/app/src/main/java/com/philliphsu/clock2/util/ProgressBarUtils.java new file mode 100644 index 0000000..c1d01d4 --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/util/ProgressBarUtils.java @@ -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; + } +} diff --git a/app/src/main/res/layout-v21/fragment_stopwatch.xml b/app/src/main/res/layout-v21/fragment_stopwatch.xml new file mode 100644 index 0000000..cc7e233 --- /dev/null +++ b/app/src/main/res/layout-v21/fragment_stopwatch.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index e69b1fb..ff82b0e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -42,7 +42,7 @@ android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="end|bottom" + android:layout_gravity="bottom|end" android:layout_margin="@dimen/fab_margin" android:src="@android:drawable/ic_dialog_email"/> diff --git a/app/src/main/res/layout/fragment_stopwatch.xml b/app/src/main/res/layout/fragment_stopwatch.xml new file mode 100644 index 0000000..2c848dc --- /dev/null +++ b/app/src/main/res/layout/fragment_stopwatch.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_lap.xml b/app/src/main/res/layout/item_lap.xml new file mode 100644 index 0000000..608317e --- /dev/null +++ b/app/src/main/res/layout/item_lap.xml @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file