1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.deskclock.stopwatch;
18 
19 import android.annotation.SuppressLint;
20 import android.app.Activity;
21 import android.content.ActivityNotFoundException;
22 import android.content.Context;
23 import android.content.Intent;
24 import android.content.res.ColorStateList;
25 import android.content.res.Resources;
26 import android.graphics.Canvas;
27 import android.graphics.drawable.GradientDrawable;
28 import android.os.Bundle;
29 import android.support.annotation.ColorInt;
30 import android.support.annotation.NonNull;
31 import android.support.v4.graphics.ColorUtils;
32 import android.support.v7.widget.LinearLayoutManager;
33 import android.support.v7.widget.RecyclerView;
34 import android.support.v7.widget.SimpleItemAnimator;
35 import android.transition.TransitionManager;
36 import android.view.LayoutInflater;
37 import android.view.MotionEvent;
38 import android.view.View;
39 import android.view.ViewGroup;
40 import android.view.WindowManager;
41 import android.widget.Button;
42 import android.widget.ImageView;
43 import android.widget.TextView;
44 
45 import com.android.deskclock.AnimatorUtils;
46 import com.android.deskclock.DeskClockFragment;
47 import com.android.deskclock.LogUtils;
48 import com.android.deskclock.R;
49 import com.android.deskclock.StopwatchTextController;
50 import com.android.deskclock.ThemeUtils;
51 import com.android.deskclock.Utils;
52 import com.android.deskclock.data.DataModel;
53 import com.android.deskclock.data.Lap;
54 import com.android.deskclock.data.Stopwatch;
55 import com.android.deskclock.data.StopwatchListener;
56 import com.android.deskclock.events.Events;
57 import com.android.deskclock.uidata.TabListener;
58 import com.android.deskclock.uidata.UiDataModel;
59 import com.android.deskclock.uidata.UiDataModel.Tab;
60 
61 import static android.R.attr.state_activated;
62 import static android.R.attr.state_pressed;
63 import static android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM;
64 import static android.view.View.GONE;
65 import static android.view.View.INVISIBLE;
66 import static android.view.View.VISIBLE;
67 import static com.android.deskclock.uidata.UiDataModel.Tab.STOPWATCH;
68 
69 /**
70  * Fragment that shows the stopwatch and recorded laps.
71  */
72 public final class StopwatchFragment extends DeskClockFragment {
73 
74     /** Milliseconds between redraws while running. */
75     private static final int REDRAW_PERIOD_RUNNING = 25;
76 
77     /** Milliseconds between redraws while paused. */
78     private static final int REDRAW_PERIOD_PAUSED = 500;
79 
80     /** Keep the screen on when this tab is selected. */
81     private final TabListener mTabWatcher = new TabWatcher();
82 
83     /** Scheduled to update the stopwatch time and current lap time while stopwatch is running. */
84     private final Runnable mTimeUpdateRunnable = new TimeUpdateRunnable();
85 
86     /** Updates the user interface in response to stopwatch changes. */
87     private final StopwatchListener mStopwatchWatcher = new StopwatchWatcher();
88 
89     /** Draws a gradient over the bottom of the {@link #mLapsList} to reduce clash with the fab. */
90     private GradientItemDecoration mGradientItemDecoration;
91 
92     /** The data source for {@link #mLapsList}. */
93     private LapsAdapter mLapsAdapter;
94 
95     /** The layout manager for the {@link #mLapsAdapter}. */
96     private LinearLayoutManager mLapsLayoutManager;
97 
98     /** Draws the reference lap while the stopwatch is running. */
99     private StopwatchCircleView mTime;
100 
101     /** The View containing both TextViews of the stopwatch. */
102     private View mStopwatchWrapper;
103 
104     /** Displays the recorded lap times. */
105     private RecyclerView mLapsList;
106 
107     /** Displays the current stopwatch time (seconds and above only). */
108     private TextView mMainTimeText;
109 
110     /** Displays the current stopwatch time (hundredths only). */
111     private TextView mHundredthsTimeText;
112 
113     /** Formats and displays the text in the stopwatch. */
114     private StopwatchTextController mStopwatchTextController;
115 
116     /** The public no-arg constructor required by all fragments. */
StopwatchFragment()117     public StopwatchFragment() {
118         super(STOPWATCH);
119     }
120 
121     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state)122     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state) {
123         mLapsAdapter = new LapsAdapter(getActivity());
124         mLapsLayoutManager = new LinearLayoutManager(getActivity());
125         mGradientItemDecoration = new GradientItemDecoration(getActivity());
126 
127         final View v = inflater.inflate(R.layout.stopwatch_fragment, container, false);
128         mTime = (StopwatchCircleView) v.findViewById(R.id.stopwatch_circle);
129         mLapsList = (RecyclerView) v.findViewById(R.id.laps_list);
130         ((SimpleItemAnimator) mLapsList.getItemAnimator()).setSupportsChangeAnimations(false);
131         mLapsList.setLayoutManager(mLapsLayoutManager);
132         mLapsList.addItemDecoration(mGradientItemDecoration);
133 
134         // In landscape layouts, the laps list can reach the top of the screen and thus can cause
135         // a drop shadow to appear. The same is not true for portrait landscapes.
136         if (Utils.isLandscape(getActivity())) {
137             final ScrollPositionWatcher scrollPositionWatcher = new ScrollPositionWatcher();
138             mLapsList.addOnLayoutChangeListener(scrollPositionWatcher);
139             mLapsList.addOnScrollListener(scrollPositionWatcher);
140         } else {
141             setTabScrolledToTop(true);
142         }
143         mLapsList.setAdapter(mLapsAdapter);
144 
145         // Timer text serves as a virtual start/stop button.
146         mMainTimeText = (TextView) v.findViewById(R.id.stopwatch_time_text);
147         mHundredthsTimeText = (TextView) v.findViewById(R.id.stopwatch_hundredths_text);
148         mStopwatchTextController = new StopwatchTextController(mMainTimeText, mHundredthsTimeText);
149         mStopwatchWrapper = v.findViewById(R.id.stopwatch_time_wrapper);
150 
151         DataModel.getDataModel().addStopwatchListener(mStopwatchWatcher);
152 
153         mStopwatchWrapper.setOnClickListener(new TimeClickListener());
154         if (mTime != null) {
155             mStopwatchWrapper.setOnTouchListener(new CircleTouchListener());
156         }
157 
158         final Context c = mMainTimeText.getContext();
159         final int colorAccent = ThemeUtils.resolveColor(c, R.attr.colorAccent);
160         final int textColorPrimary = ThemeUtils.resolveColor(c, android.R.attr.textColorPrimary);
161         final ColorStateList timeTextColor = new ColorStateList(
162                 new int[][] { { -state_activated, -state_pressed }, {} },
163                 new int[] { textColorPrimary, colorAccent });
164         mMainTimeText.setTextColor(timeTextColor);
165         mHundredthsTimeText.setTextColor(timeTextColor);
166 
167         return v;
168     }
169 
170     @Override
onStart()171     public void onStart() {
172         super.onStart();
173 
174         final Activity activity = getActivity();
175         final Intent intent = activity.getIntent();
176         if (intent != null) {
177             final String action = intent.getAction();
178             if (StopwatchService.ACTION_START_STOPWATCH.equals(action)) {
179                 DataModel.getDataModel().startStopwatch();
180                 // Consume the intent
181                 activity.setIntent(null);
182             } else if (StopwatchService.ACTION_PAUSE_STOPWATCH.equals(action)) {
183                 DataModel.getDataModel().pauseStopwatch();
184                 // Consume the intent
185                 activity.setIntent(null);
186             }
187         }
188 
189         // Conservatively assume the data in the adapter has changed while the fragment was paused.
190         mLapsAdapter.notifyDataSetChanged();
191 
192         // Synchronize the user interface with the data model.
193         updateUI(FAB_AND_BUTTONS_IMMEDIATE);
194 
195         // Start watching for page changes away from this fragment.
196         UiDataModel.getUiDataModel().addTabListener(mTabWatcher);
197     }
198 
199     @Override
onStop()200     public void onStop() {
201         super.onStop();
202 
203         // Stop all updates while the fragment is not visible.
204         stopUpdatingTime();
205 
206         // Stop watching for page changes away from this fragment.
207         UiDataModel.getUiDataModel().removeTabListener(mTabWatcher);
208 
209         // Release the wake lock if it is currently held.
210         releaseWakeLock();
211     }
212 
213     @Override
onDestroyView()214     public void onDestroyView() {
215         super.onDestroyView();
216 
217         DataModel.getDataModel().removeStopwatchListener(mStopwatchWatcher);
218     }
219 
220     @Override
onFabClick(@onNull ImageView fab)221     public void onFabClick(@NonNull ImageView fab) {
222         toggleStopwatchState();
223     }
224 
225     @Override
onLeftButtonClick(@onNull Button left)226     public void onLeftButtonClick(@NonNull Button left) {
227         doReset();
228     }
229 
230     @Override
onRightButtonClick(@onNull Button right)231     public void onRightButtonClick(@NonNull Button right) {
232         switch (getStopwatch().getState()) {
233             case RUNNING:
234                 doAddLap();
235                 break;
236             case PAUSED:
237                 doShare();
238                 break;
239         }
240     }
241 
updateFab(@onNull ImageView fab, boolean animate)242     private void updateFab(@NonNull ImageView fab, boolean animate) {
243         if (getStopwatch().isRunning()) {
244             if (animate) {
245                 fab.setImageResource(R.drawable.ic_play_pause_animation);
246             } else {
247                 fab.setImageResource(R.drawable.ic_play_pause);
248             }
249             fab.setContentDescription(fab.getResources().getString(R.string.sw_pause_button));
250         } else {
251             if (animate) {
252                 fab.setImageResource(R.drawable.ic_pause_play_animation);
253             } else {
254                 fab.setImageResource(R.drawable.ic_pause_play);
255             }
256             fab.setContentDescription(fab.getResources().getString(R.string.sw_start_button));
257         }
258         fab.setVisibility(VISIBLE);
259     }
260 
onUpdateFab(@onNull ImageView fab)261     public void onUpdateFab(@NonNull ImageView fab) {
262         updateFab(fab, false);
263     }
264 
265     @Override
onMorphFab(@onNull ImageView fab)266     public void onMorphFab(@NonNull ImageView fab) {
267         // Update the fab's drawable to match the current timer state.
268         updateFab(fab, Utils.isNOrLater());
269         // Animate the drawable.
270         AnimatorUtils.startDrawableAnimation(fab);
271     }
272 
273     @Override
onUpdateFabButtons(@onNull Button left, @NonNull Button right)274     public void onUpdateFabButtons(@NonNull Button left, @NonNull Button right) {
275         final Resources resources = getResources();
276         left.setClickable(true);
277         left.setText(R.string.sw_reset_button);
278         left.setContentDescription(resources.getString(R.string.sw_reset_button));
279 
280         switch (getStopwatch().getState()) {
281             case RESET:
282                 left.setVisibility(INVISIBLE);
283                 right.setClickable(true);
284                 right.setVisibility(INVISIBLE);
285                 break;
286             case RUNNING:
287                 left.setVisibility(VISIBLE);
288                 final boolean canRecordLaps = canRecordMoreLaps();
289                 right.setText(R.string.sw_lap_button);
290                 right.setContentDescription(resources.getString(R.string.sw_lap_button));
291                 right.setClickable(canRecordLaps);
292                 right.setVisibility(canRecordLaps ? VISIBLE : INVISIBLE);
293                 break;
294             case PAUSED:
295                 left.setVisibility(VISIBLE);
296                 right.setClickable(true);
297                 right.setVisibility(VISIBLE);
298                 right.setText(R.string.sw_share_button);
299                 right.setContentDescription(resources.getString(R.string.sw_share_button));
300                 break;
301         }
302     }
303 
304     /**
305      * @param color the newly installed app window color
306      */
onAppColorChanged(@olorInt int color)307     protected void onAppColorChanged(@ColorInt int color) {
308         if (mGradientItemDecoration != null) {
309             mGradientItemDecoration.updateGradientColors(color);
310         }
311         if (mLapsList != null) {
312             mLapsList.invalidateItemDecorations();
313         }
314     }
315 
316     /**
317      * Start the stopwatch.
318      */
doStart()319     private void doStart() {
320         Events.sendStopwatchEvent(R.string.action_start, R.string.label_deskclock);
321         DataModel.getDataModel().startStopwatch();
322     }
323 
324     /**
325      * Pause the stopwatch.
326      */
doPause()327     private void doPause() {
328         Events.sendStopwatchEvent(R.string.action_pause, R.string.label_deskclock);
329         DataModel.getDataModel().pauseStopwatch();
330     }
331 
332     /**
333      * Reset the stopwatch.
334      */
doReset()335     private void doReset() {
336         final Stopwatch.State priorState = getStopwatch().getState();
337         Events.sendStopwatchEvent(R.string.action_reset, R.string.label_deskclock);
338         DataModel.getDataModel().resetStopwatch();
339         mMainTimeText.setAlpha(1f);
340         mHundredthsTimeText.setAlpha(1f);
341         if (priorState == Stopwatch.State.RUNNING) {
342             updateFab(FAB_MORPH);
343         }
344     }
345 
346     /**
347      * Send stopwatch time and lap times to an external sharing application.
348      */
doShare()349     private void doShare() {
350         // Disable the fab buttons to avoid double-taps on the share button.
351         updateFab(BUTTONS_DISABLE);
352 
353         final String[] subjects = getResources().getStringArray(R.array.sw_share_strings);
354         final String subject = subjects[(int) (Math.random() * subjects.length)];
355         final String text = mLapsAdapter.getShareText();
356 
357         @SuppressLint("InlinedApi")
358         @SuppressWarnings("deprecation")
359         final Intent shareIntent = new Intent(Intent.ACTION_SEND)
360                 .addFlags(Utils.isLOrLater() ? Intent.FLAG_ACTIVITY_NEW_DOCUMENT
361                         : Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET)
362                 .putExtra(Intent.EXTRA_SUBJECT, subject)
363                 .putExtra(Intent.EXTRA_TEXT, text)
364                 .setType("text/plain");
365 
366         final Context context = getActivity();
367         final String title = context.getString(R.string.sw_share_button);
368         final Intent shareChooserIntent = Intent.createChooser(shareIntent, title);
369         try {
370             context.startActivity(shareChooserIntent);
371         } catch (ActivityNotFoundException anfe) {
372             LogUtils.e("Cannot share lap data because no suitable receiving Activity exists");
373             updateFab(BUTTONS_IMMEDIATE);
374         }
375     }
376 
377     /**
378      * Record and add a new lap ending now.
379      */
doAddLap()380     private void doAddLap() {
381         Events.sendStopwatchEvent(R.string.action_lap, R.string.label_deskclock);
382 
383         // Record a new lap.
384         final Lap lap = mLapsAdapter.addLap();
385         if (lap == null) {
386             return;
387         }
388 
389         // Update button states.
390         updateFab(BUTTONS_IMMEDIATE);
391 
392         if (lap.getLapNumber() == 1) {
393             // Child views from prior lap sets hang around and blit to the screen when adding the
394             // first lap of the subsequent lap set. Remove those superfluous children here manually
395             // to ensure they aren't seen as the first lap is drawn.
396             mLapsList.removeAllViewsInLayout();
397 
398             if (mTime != null) {
399                 // Start animating the reference lap.
400                 mTime.update();
401             }
402 
403             // Recording the first lap transitions the UI to display the laps list.
404             showOrHideLaps(false);
405         }
406 
407         // Ensure the newly added lap is visible on screen.
408         mLapsList.scrollToPosition(0);
409     }
410 
411     /**
412      * Show or hide the list of laps.
413      */
showOrHideLaps(boolean clearLaps)414     private void showOrHideLaps(boolean clearLaps) {
415         final ViewGroup sceneRoot = (ViewGroup) getView();
416         if (sceneRoot == null) {
417             return;
418         }
419 
420         TransitionManager.beginDelayedTransition(sceneRoot);
421 
422         if (clearLaps) {
423             mLapsAdapter.clearLaps();
424         }
425 
426         final boolean lapsVisible = mLapsAdapter.getItemCount() > 0;
427         mLapsList.setVisibility(lapsVisible ? VISIBLE : GONE);
428 
429         if (Utils.isPortrait(getActivity())) {
430             // When the lap list is visible, it includes the bottom padding. When it is absent the
431             // appropriate bottom padding must be applied to the container.
432             final Resources res = getResources();
433             final int bottom = lapsVisible ? 0 : res.getDimensionPixelSize(R.dimen.fab_height);
434             final int top = sceneRoot.getPaddingTop();
435             final int left = sceneRoot.getPaddingLeft();
436             final int right = sceneRoot.getPaddingRight();
437             sceneRoot.setPadding(left, top, right, bottom);
438         }
439     }
440 
adjustWakeLock()441     private void adjustWakeLock() {
442         final boolean appInForeground = DataModel.getDataModel().isApplicationInForeground();
443         if (getStopwatch().isRunning() && isTabSelected() && appInForeground) {
444             getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
445         } else {
446             releaseWakeLock();
447         }
448     }
449 
releaseWakeLock()450     private void releaseWakeLock() {
451         getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
452     }
453 
454     /**
455      * Either pause or start the stopwatch based on its current state.
456      */
toggleStopwatchState()457     private void toggleStopwatchState() {
458         if (getStopwatch().isRunning()) {
459             doPause();
460         } else {
461             doStart();
462         }
463     }
464 
getStopwatch()465     private Stopwatch getStopwatch() {
466         return DataModel.getDataModel().getStopwatch();
467     }
468 
canRecordMoreLaps()469     private boolean canRecordMoreLaps() {
470         return DataModel.getDataModel().canAddMoreLaps();
471     }
472 
473     /**
474      * Post the first runnable to update times within the UI. It will reschedule itself as needed.
475      */
startUpdatingTime()476     private void startUpdatingTime() {
477         // Ensure only one copy of the runnable is ever scheduled by first stopping updates.
478         stopUpdatingTime();
479         mMainTimeText.post(mTimeUpdateRunnable);
480     }
481 
482     /**
483      * Remove the runnable that updates times within the UI.
484      */
stopUpdatingTime()485     private void stopUpdatingTime() {
486         mMainTimeText.removeCallbacks(mTimeUpdateRunnable);
487     }
488 
489     /**
490      * Update all time displays based on a single snapshot of the stopwatch progress. This includes
491      * the stopwatch time drawn in the circle, the current lap time and the total elapsed time in
492      * the list of laps.
493      */
updateTime()494     private void updateTime() {
495         // Compute the total time of the stopwatch.
496         final Stopwatch stopwatch = getStopwatch();
497         final long totalTime = stopwatch.getTotalTime();
498         mStopwatchTextController.setTimeString(totalTime);
499 
500         // Update the current lap.
501         final boolean currentLapIsVisible = mLapsLayoutManager.findFirstVisibleItemPosition() == 0;
502         if (!stopwatch.isReset() && currentLapIsVisible) {
503             mLapsAdapter.updateCurrentLap(mLapsList, totalTime);
504         }
505     }
506 
507     /**
508      * Synchronize the UI state with the model data.
509      */
updateUI(@pdateFabFlag int updateTypes)510     private void updateUI(@UpdateFabFlag int updateTypes) {
511         adjustWakeLock();
512 
513         // Draw the latest stopwatch and current lap times.
514         updateTime();
515 
516         if (mTime != null) {
517             mTime.update();
518         }
519 
520         final Stopwatch stopwatch = getStopwatch();
521         if (!stopwatch.isReset()) {
522             startUpdatingTime();
523         }
524 
525         // Adjust the visibility of the list of laps.
526         showOrHideLaps(stopwatch.isReset());
527 
528         // Update button states.
529         updateFab(updateTypes);
530     }
531 
532     /**
533      * This runnable periodically updates times throughout the UI. It stops these updates when the
534      * stopwatch is no longer running.
535      */
536     private final class TimeUpdateRunnable implements Runnable {
537         @Override
run()538         public void run() {
539             final long startTime = Utils.now();
540 
541             updateTime();
542 
543             // Blink text iff the stopwatch is paused and not pressed.
544             final View touchTarget = mTime != null ? mTime : mStopwatchWrapper;
545             final Stopwatch stopwatch = getStopwatch();
546             final boolean blink = stopwatch.isPaused()
547                     && startTime % 1000 < 500
548                     && !touchTarget.isPressed();
549 
550             if (blink) {
551                 mMainTimeText.setAlpha(0f);
552                 mHundredthsTimeText.setAlpha(0f);
553             } else {
554                 mMainTimeText.setAlpha(1f);
555                 mHundredthsTimeText.setAlpha(1f);
556             }
557 
558             if (!stopwatch.isReset()) {
559                 final long period = stopwatch.isPaused()
560                         ? REDRAW_PERIOD_PAUSED
561                         : REDRAW_PERIOD_RUNNING;
562                 final long endTime = Utils.now();
563                 final long delay = Math.max(0, startTime + period - endTime);
564                 mMainTimeText.postDelayed(this, delay);
565             }
566         }
567     }
568 
569     /**
570      * Acquire or release the wake lock based on the tab state.
571      */
572     private final class TabWatcher implements TabListener {
573         @Override
574         public void selectedTabChanged(Tab oldSelectedTab, Tab newSelectedTab) {
575             adjustWakeLock();
576         }
577     }
578 
579     /**
580      * Update the user interface in response to a stopwatch change.
581      */
582     private class StopwatchWatcher implements StopwatchListener {
583         @Override
584         public void stopwatchUpdated(Stopwatch before, Stopwatch after) {
585             if (after.isReset()) {
586                 // Ensure the drop shadow is hidden when the stopwatch is reset.
587                 setTabScrolledToTop(true);
588                 if (DataModel.getDataModel().isApplicationInForeground()) {
589                     updateUI(BUTTONS_IMMEDIATE);
590                 }
591                 return;
592             }
593             if (DataModel.getDataModel().isApplicationInForeground()) {
594                 updateUI(FAB_MORPH | BUTTONS_IMMEDIATE);
595             }
596         }
597 
598         @Override
599         public void lapAdded(Lap lap) {
600         }
601     }
602 
603     /**
604      * Toggles stopwatch state when user taps stopwatch.
605      */
606     private final class TimeClickListener implements View.OnClickListener {
607         @Override
608         public void onClick(View view) {
609             if (getStopwatch().isRunning()) {
610                 DataModel.getDataModel().pauseStopwatch();
611             } else {
612                 DataModel.getDataModel().startStopwatch();
613             }
614         }
615     }
616 
617     /**
618      * Checks if the user is pressing inside of the stopwatch circle.
619      */
620     private final class CircleTouchListener implements View.OnTouchListener {
621         @Override
622         public boolean onTouch(View view, MotionEvent event) {
623             final int actionMasked = event.getActionMasked();
624             if (actionMasked != MotionEvent.ACTION_DOWN) {
625                 return false;
626             }
627             final float rX = view.getWidth() / 2f;
628             final float rY = (view.getHeight() - view.getPaddingBottom()) / 2f;
629             final float r = Math.min(rX, rY);
630 
631             final float x = event.getX() - rX;
632             final float y = event.getY() - rY;
633 
634             final boolean inCircle = Math.pow(x / r, 2.0) + Math.pow(y / r, 2.0) <= 1.0;
635 
636             // Consume the event if it is outside the circle
637             return !inCircle;
638         }
639     }
640 
641     /**
642      * Updates the vertical scroll state of this tab in the {@link UiDataModel} as the user scrolls
643      * the recyclerview or when the size/position of elements within the recyclerview changes.
644      */
645     private final class ScrollPositionWatcher extends RecyclerView.OnScrollListener
646             implements View.OnLayoutChangeListener {
647         @Override
648         public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
649             setTabScrolledToTop(Utils.isScrolledToTop(mLapsList));
650         }
651 
652         @Override
653         public void onLayoutChange(View v, int left, int top, int right, int bottom,
654                 int oldLeft, int oldTop, int oldRight, int oldBottom) {
655             setTabScrolledToTop(Utils.isScrolledToTop(mLapsList));
656         }
657     }
658 
659     /**
660      * Draws a tinting gradient over the bottom of the stopwatch laps list. This reduces the
661      * contrast between floating buttons and the laps list content.
662      */
663     private static final class GradientItemDecoration extends RecyclerView.ItemDecoration {
664 
665         //  0% -  25% of gradient length -> opacity changes from 0% to 50%
666         // 25% -  90% of gradient length -> opacity changes from 50% to 100%
667         // 90% - 100% of gradient length -> opacity remains at 100%
668         private static final int[] ALPHAS = {
669                 0x00, // 0%
670                 0x1A, // 10%
671                 0x33, // 20%
672                 0x4D, // 30%
673                 0x66, // 40%
674                 0x80, // 50%
675                 0x89, // 53.8%
676                 0x93, // 57.6%
677                 0x9D, // 61.5%
678                 0xA7, // 65.3%
679                 0xB1, // 69.2%
680                 0xBA, // 73.0%
681                 0xC4, // 76.9%
682                 0xCE, // 80.7%
683                 0xD8, // 84.6%
684                 0xE2, // 88.4%
685                 0xEB, // 92.3%
686                 0xF5, // 96.1%
687                 0xFF, // 100%
688                 0xFF, // 100%
689                 0xFF, // 100%
690         };
691 
692         /**
693          * A reusable array of control point colors that define the gradient. It is based on the
694          * background color of the window and thus recomputed each time that color is changed.
695          */
696         private final int[] mGradientColors = new int[ALPHAS.length];
697 
698         /** The drawable that produces the tinting gradient effect of this decoration. */
699         private final GradientDrawable mGradient = new GradientDrawable();
700 
701         /** The height of the gradient; sized relative to the fab height. */
702         private final int mGradientHeight;
703 
704         GradientItemDecoration(Context context) {
705             mGradient.setOrientation(TOP_BOTTOM);
706             updateGradientColors(ThemeUtils.resolveColor(context, android.R.attr.windowBackground));
707 
708             final Resources resources = context.getResources();
709             final float fabHeight = resources.getDimensionPixelSize(R.dimen.fab_height);
710             mGradientHeight = Math.round(fabHeight * 1.2f);
711         }
712 
713         @Override
714         public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
715             super.onDrawOver(c, parent, state);
716 
717             final int w = parent.getWidth();
718             final int h = parent.getHeight();
719 
720             mGradient.setBounds(0, h - mGradientHeight, w, h);
721             mGradient.draw(c);
722         }
723 
724         /**
725          * Given a {@code baseColor}, compute a gradient of tinted colors that define the fade
726          * effect to apply to the bottom of the lap list.
727          *
728          * @param baseColor a base color to which the gradient tint should be applied
729          */
730         void updateGradientColors(@ColorInt int baseColor) {
731             // Compute the tinted colors that form the gradient.
732             for (int i = 0; i < mGradientColors.length; i++) {
733                 mGradientColors[i] = ColorUtils.setAlphaComponent(baseColor, ALPHAS[i]);
734             }
735 
736             // Set the gradient colors into the drawable.
737             mGradient.setColors(mGradientColors);
738         }
739     }
740 }
741